diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index a8fbca6..eaf50b7 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -240,6 +240,10 @@ export class LocalGame { handOf(player: number): number[] { return [...this.hands[player]]; } + /** resignedOf reports whether a seat has resigned or been excluded by the host. */ + resignedOf(player: number): boolean { + return this.resigned[player] ?? false; + } /** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */ get winnerIndex(): number { return winner(this.over, this.reason, this.scores, this.resigned); diff --git a/ui/src/lib/localgame/source.hotseat.test.ts b/ui/src/lib/localgame/source.hotseat.test.ts index 39f28ba..8f3eff4 100644 --- a/ui/src/lib/localgame/source.hotseat.test.ts +++ b/ui/src/lib/localgame/source.hotseat.test.ts @@ -101,6 +101,19 @@ describe('LocalSource hotseat', () => { expect((st as StateView).game.status).toBe('finished'); }); + it('exposes a host-excluded seat as resigned in the view (for the medal ranking)', async () => { + const src = await hotseat('local:hR', [ + { kind: 'human', name: 'A' }, + { kind: 'human', name: 'B' }, + { kind: 'human', name: 'C' }, + ], '4321'); + await src.hostAction('local:hR', '4321', 'resign', 1); // exclude seat 1 (game continues: 2 active) + const st = await src.gameState('local:hR'); + expect(st.game.seats[1].resigned).toBe(true); + expect(st.game.seats[0].resigned).toBe(false); + expect(st.game.seats[2].resigned).toBe(false); + }); + it('lets the host terminate the game, deleting it', async () => { const src = await hotseat('local:h6', [ { kind: 'human', name: 'A' }, diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 37d56de..5f890ed 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -464,6 +464,7 @@ export class LocalSource implements GameLoopSource { score: game.scoreOf(i), hintsUsed: 0, isWinner: game.isOver && i === game.winnerIndex, + resigned: game.resignedOf(i), })); return { id: record.id, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 8b22cbb..9823e52 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -29,6 +29,9 @@ export interface Seat { score: number; hintsUsed: number; isWinner: boolean; + /** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so + * the finished-game medal ranking can place it last (no medal); undefined for online seats. */ + resigned?: boolean; } export interface GameView { diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 0c49376..1496d41 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'; import { resultBadge, seatMedal } from './result'; import type { GameView, Seat } from './model'; -const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({ +const seat = (s: number, accountId: string, score: number, isWinner = false, resigned = false): Seat => ({ seat: s, accountId, displayName: accountId, score, hintsUsed: 0, isWinner, + resigned, }); function game(seats: Seat[], status = 'finished', toMove = 0): GameView { @@ -81,23 +82,27 @@ describe('seatMedal', () => { expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe(''); }); - it('finished two-player: winner gets the trophy, the other second place', () => { - const g = game([seat(0, 'a', 300, true), seat(1, 'b', 200)]); - expect(seatMedal(g, 0)).toBe('🏆'); - expect(seatMedal(g, 1)).toBe('🥈'); - }); - - it('finished draw (no winner): every seat gets the medal', () => { - const g = game([seat(0, 'a', 200), seat(1, 'b', 200)]); - expect(seatMedal(g, 0)).toBe('🏅'); - expect(seatMedal(g, 1)).toBe('🏅'); - }); - - it('finished four-player: places each seat by score', () => { + it('finished: places each seat by final score', () => { const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]); - expect(seatMedal(g, 1)).toBe('🏆'); // winner - expect(seatMedal(g, 2)).toBe('🥈'); // 2nd by score - expect(seatMedal(g, 3)).toBe('🥉'); // 3rd - expect(seatMedal(g, 0)).toBe('🏅'); // 4th + expect(seatMedal(g, 1)).toBe('🏆'); // 400 → 1st + expect(seatMedal(g, 2)).toBe('🥈'); // 300 → 2nd + expect(seatMedal(g, 3)).toBe('🥉'); // 200 → 3rd + expect(seatMedal(g, 0)).toBe('🏅'); // 100 → 4th + }); + + it('a tie for the lead shares first place; the rest place below (competition ranking)', () => { + // The owner's case: a scoreless end deducted racks to -8 / -14 / -8, tying seats 0 and 2 for the + // lead — they must SHARE the trophy, not all show the last-place medal (the reported bug). + const g = game([seat(0, 'a', -8), seat(1, 'b', -14), seat(2, 'c', -8)]); + expect(seatMedal(g, 0)).toBe('🏆'); + expect(seatMedal(g, 2)).toBe('🏆'); + expect(seatMedal(g, 1)).toBe('🥉'); // two seats rank above it → 3rd + }); + + it('an excluded / resigned seat gets no medal and does not push the others down', () => { + const g = game([seat(0, 'a', 50), seat(1, 'b', 900, false, true), seat(2, 'c', 30)]); + expect(seatMedal(g, 1)).toBe(''); // resigned: no medal, though its frozen score is the highest + expect(seatMedal(g, 0)).toBe('🏆'); // ranked among the rest: highest → 1st + expect(seatMedal(g, 2)).toBe('🥈'); }); }); diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts index d88968a..a57cf42 100644 --- a/ui/src/lib/result.ts +++ b/ui/src/lib/result.ts @@ -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 ? '🥉' : '🏅'; }