fix(offline): hotseat finished-game medals by final score (fix all-last-place on a tie)
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).
This commit is contained in:
Ilia Denisov
2026-07-07 15:37:25 +02:00
parent 84d0385c95
commit 1e087be90a
6 changed files with 52 additions and 27 deletions
+8 -9
View File
@@ -34,17 +34,16 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
/**
* 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). The winner takes the trophy; the
* rest place by score (a draw — no
* winner — gives every seat the medal). Mirrors the ranking in resultBadge, keyed by seat not viewer.
* "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) return '';
if (s.isWinner) return '🏆';
if (!game.seats.some((x) => x.isWinner)) return '🏅'; // a draw: no winner
const ahead = game.seats.filter((x) => !x.isWinner && x.seat !== seat && x.score > s.score).length;
const rank = 2 + ahead;
return rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
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 ? '🥉' : '🏅';
}