diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 04e2604..e76709f 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1107,7 +1107,15 @@ if (!view) return ''; const me = view.game.seats[view.seat]; if (me?.isWinner) return t('game.won'); - return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); + if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone + const myScore = me?.score ?? 0; + // A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss. + if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) { + return t('game.lost'); + } + // No one above me and no declared winner: a tie for the lead is a (shared) win; only an + // all-level finish is a draw. + return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won'); } // The finished-game export offers two formats — the GCG file and the server-rendered PNG diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 1496d41..2ff3dc8 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -75,6 +75,18 @@ describe('resultBadge', () => { const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]); expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' }); }); + + it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => { + // The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single + // winner(), so this must NOT read as a full draw for everyone. + expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' }); + expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' }); + }); + + it('an aborted game reads as a draw for everyone, whatever the scores', () => { + const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' }; + expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' }); + }); }); describe('seatMedal', () => { diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts index a57cf42..ea83fb5 100644 --- a/ui/src/lib/result.ts +++ b/ui/src/lib/result.ts @@ -9,6 +9,13 @@ export interface ResultBadge { 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, myId: string): ResultBadge { const me = game.seats.find((s) => s.accountId === myId); @@ -19,16 +26,24 @@ export function resultBadge(game: GameView, myId: string): ResultBadge { } 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: '🏅' }; + // 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 && s.accountId !== myId && s.score > (me?.score ?? 0)).length; + return placeBadge(2 + ahead, game.players); } /**