diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 43c4ba9..ab27cb6 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -23,10 +23,13 @@ const DIST = 'dist'; // wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for // the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in // the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and -// countdown toast live in the always-loaded game screen (Game.svelte). The heavy parts — the dict -// loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS -// lands in the CSS chunk, not this JS budget. -const BUDGET = { app: 115, shared: 30, landing: 5 }; +// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline +// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu +// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN +// hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move +// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS +// chunk, not this JS budget. +const BUDGET = { app: 120, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/lib/localgame/source.hotseat.test.ts b/ui/src/lib/localgame/source.hotseat.test.ts index f7e8a0d..e8a5a9f 100644 --- a/ui/src/lib/localgame/source.hotseat.test.ts +++ b/ui/src/lib/localgame/source.hotseat.test.ts @@ -120,13 +120,14 @@ describe('LocalSource hotseat', () => { expect(await src.verifyHostPin('local:h7', '0000')).toBe(false); }); - it('persists a naturally finished game (scoreless run) as finished', async () => { + it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => { const src = await hotseat('local:h8', [ - { kind: 'human', name: 'A' }, - { kind: 'human', name: 'B' }, + { kind: 'human', name: 'A', pin: await newLock('1111') }, + { kind: 'human', name: 'B', pin: await newLock('2222') }, ]); for (let i = 0; i < 6; i++) await src.pass('local:h8'); const st = await src.gameState('local:h8'); expect(st.game.status).toBe('finished'); + expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button }); }); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 909d0f1..1fcd8d7 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -418,7 +418,8 @@ export class LocalSource implements GameLoopSource { private stateView(entry: Live): StateView { const hotseat = !!entry.record.hotseat; const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry); - const locked = hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat; + // A finished game is never locked — its final rack is shown for review, not an unlock button. + const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat; const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); return { game: this.gameView(entry),