fix(ui): unify tie-for-lead result across lobby + game (shared victory, not a draw)
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 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
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 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Bring the online lobby badge and the in-game 'you won/lost' text to the same competition ranking the hotseat seat medals use: in a 3-4 player game a TIE for the lead is a SHARED victory for the top scorers and a placed finish for those below — not a full draw for everyone (winner() is -1 on any tie, so isWinner alone conflated them). An aborted game and a genuine all-level finish stay a draw; a win by resignation (winner at a not-higher score) is unchanged. - result.ts: resultBadge no-winner branch ranks by final score; placeBadge helper; resigned seats excluded from the ranking. - Game.svelte: resultText aligned (shared lead = won, below = lost).
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
+24
-9
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user