1e087be90a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
Reported: a hotseat game ended by 6 scoreless passes deducts each rack (correct rule, same as online: -8/-14/-8 here), which tied two seats for the lead. The engine's winner() returns -1 on a tie, so seatMedal read it as a full draw and gave EVERY seat the last-place medal. - result.ts: seatMedal now ranks by the FINAL score (competition ranking — a tie for the lead shares the trophy), not the single-winner flag. A resigned / host-excluded seat places last with no medal and does not push the others down. - model.ts: Seat.resigned (offline-only); engine.resignedOf getter; the local source surfaces it on the game view. - The scoreless rack deduction is unchanged (standard rules, owner-confirmed).
50 lines
2.4 KiB
TypeScript
50 lines
2.4 KiB
TypeScript
// Pure mapping from a game view (for the viewer) to a status/result badge: a label key
|
|
// and a place-based emoji. Used by the lobby lists.
|
|
|
|
import type { GameView } from './model';
|
|
import type { MessageKey } from './i18n/catalog';
|
|
|
|
export interface ResultBadge {
|
|
key: MessageKey;
|
|
emoji: string;
|
|
}
|
|
|
|
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
|
const me = game.seats.find((s) => s.accountId === myId);
|
|
|
|
if (game.status === 'active' || game.status === 'open') {
|
|
return game.toMove === me?.seat
|
|
? { key: 'result.yourMove', emoji: '🟢' }
|
|
: { key: 'result.oppMove', emoji: '⏳' };
|
|
}
|
|
|
|
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
|
|
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
|
|
|
|
// Someone else won and it is not me, so I did not win — even when scores are level (a
|
|
// win by resignation or timeout can leave the winner at or below my score). The winner
|
|
// takes rank 1; place me among the remaining seats by score, starting at rank 2.
|
|
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
|
|
const rank = 2 + ahead;
|
|
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
|
|
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
|
return { key: 'result.place4', emoji: '🏅' };
|
|
}
|
|
|
|
/**
|
|
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
|
|
* so a hotseat game can show each player's medal on their plaque instead of a single viewer-centric
|
|
* "you won/lost" (which is meaningless with 2-4 local players). It ranks by the FINAL score with
|
|
* competition ranking (equal scores share a place), so a tie for the lead SHARES the trophy rather
|
|
* than reading as a full draw. A resigned / host-excluded seat places last, with no medal, and does
|
|
* not push the remaining players down. Ranked by score, not by the engine's single-winner flag
|
|
* (which is -1 on any tie for the lead — the reported all-last-place bug).
|
|
*/
|
|
export function seatMedal(game: GameView, seat: number): string {
|
|
if (game.status !== 'finished') return '';
|
|
const s = game.seats.find((x) => x.seat === seat);
|
|
if (!s || s.resigned) return '';
|
|
const rank = 1 + game.seats.filter((x) => !x.resigned && x.score > s.score).length;
|
|
return rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
|
|
}
|