// 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; } /** placeBadge maps a 1-based finishing rank to its label + medal (2nd is a defeat in a duel). */ function placeBadge(rank: number, players: number): ResultBadge { if (rank === 2) return players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' }; if (rank === 3) return { key: 'result.place3', emoji: '🥉' }; return { key: 'result.place4', emoji: '🏅' }; } export function resultBadge(game: GameView, myIds: readonly string[]): ResultBadge { const me = game.seats.find((s) => myIds.includes(s.accountId)); 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: '🏆' }; // No single winner: a tie for the lead, a full draw, or an aborted game. An abort or an all-level // finish is a draw; a tie for the lead is a SHARED victory for the top scorers, and a placed finish // for those below. (winner() is -1 on ANY tie for the lead, so isWinner alone cannot tell a // three-way "two share first, one is last" apart from a genuine draw — the reported bug.) if (!game.seats.some((s) => s.isWinner)) { const myScore = me?.score ?? 0; const higher = game.seats.filter((s) => !s.resigned && s.score > myScore).length; const allLevel = game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore); if (game.endReason === 'aborted' || (higher === 0 && allLevel)) return { key: 'result.draw', emoji: '🏅' }; return higher === 0 ? { key: 'result.victory', emoji: '🏆' } : placeBadge(1 + higher, game.players); } // A winner exists but it is not me — 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 // (non-resigned) seats by score, starting at rank 2. const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && !myIds.includes(s.accountId) && s.score > (me?.score ?? 0)).length; return placeBadge(2 + ahead, game.players); } /** * 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 ? '🥉' : '🏅'; }