From 359af83c34c10df7722ffc975b6eb9efecfc5aac Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 23:11:17 +0200 Subject: [PATCH] fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby Two pre-existing offline bugs, both exposed once offline mode became usable (cold boot + toggling), owner-reported on the contour. Bug 1 -- offline tiles render weight 0. The rack/board read tile values from the lib/alphabet cache, populated only by the online wire codec (server-sent alphabet) or the mock (so the e2e masked it). A local game never goes through the codec, so on a cold offline boot with no prior online session the cache was empty and every value read 0 (scores stayed correct -- they come from the offline ruleset). Fix: the local source seeds lib/alphabet from the static offline ruleset (letters + values) when it builds a game view. Bug 2 -- the lobby showed (and could open) the other mode's game after a toggle. Two causes: (a) the lobby cache was not mode-tagged, so getLobby() instant-rendered the last snapshot regardless of mode; (b) load() wrote the module list after an await with no generation guard, so a slow online gamesList() finishing after a fast offline list() (or vice versa) left the wrong mode's games on screen. Fix: tag the snapshot with offline and gate getLobby() on it; add a load-sequence guard so only the latest load() writes. - localgame/source.ts: ensureAlphabet from the ruleset in gameView (+ unit test). - lobbycache.ts: LobbySnapshot.offline + getLobby(offline) mode check (+ tests). - Lobby.svelte: setLobby tags the mode; load() carries a generation guard. check 0 / unit 485 / e2e 198 / app entry 113.8/114. --- ui/src/lib/lobbycache.test.ts | 64 ++++++++++++++++++----------- ui/src/lib/lobbycache.ts | 11 +++-- ui/src/lib/localgame/source.test.ts | 13 ++++++ ui/src/lib/localgame/source.ts | 15 +++++++ ui/src/screens/Lobby.svelte | 26 ++++++++---- 5 files changed, 95 insertions(+), 34 deletions(-) diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index 0e8f4b9..4e43c00 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -43,10 +43,10 @@ beforeEach(() => clearLobby()); describe('patchLobbyGame', () => { it('replaces the matching game by id and leaves the others untouched', () => { - setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false }); + setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false, offline: false }); // The player's own move flipped game "a" to the opponent's turn. patchLobbyGame(gameView('a', 'active', 1)); - const snap = getLobby(); + const snap = getLobby(false); expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']); expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1); expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0); @@ -54,27 +54,27 @@ describe('patchLobbyGame', () => { it('preserves invitations and incoming when patching a game', () => { const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; - setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false }); + setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false, offline: false }); patchLobbyGame(gameView('a', 'finished')); - expect(getLobby()?.incoming).toEqual(incoming); - expect(getLobby()?.games[0].status).toBe('finished'); + expect(getLobby(false)?.incoming).toEqual(incoming); + expect(getLobby(false)?.games[0].status).toBe('finished'); }); it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => { - setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false }); + setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false, offline: false }); patchLobbyGame(gameView('z', 'active')); // Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership. - expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']); + expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']); }); it('is a no-op when there is no cached lobby yet', () => { patchLobbyGame(gameView('a')); - expect(getLobby()).toBeNull(); + expect(getLobby(false)).toBeNull(); }); it('does not mutate the previous snapshot array', () => { const games = [gameView('a', 'active', 0)]; - setLobby({ games, invitations: [], incoming: [], atGameLimit: false }); + setLobby({ games, invitations: [], incoming: [], atGameLimit: false, offline: false }); patchLobbyGame(gameView('a', 'active', 1)); expect(games[0].toMove).toBe(0); // the original array/object is left intact }); @@ -82,51 +82,67 @@ describe('patchLobbyGame', () => { it('preserves the at-game-limit flag across a game patch', () => { // A finished-game patch arriving while the lobby is unmounted must not drop the flag — // the lobby renders the "New Game" button from the cached snapshot before the refresh. - setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true }); + setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true, offline: false }); patchLobbyGame(gameView('a', 'finished')); - expect(getLobby()?.atGameLimit).toBe(true); + expect(getLobby(false)?.atGameLimit).toBe(true); }); }); describe('patchLobbyInvitation', () => { it('adds a new pending invitation to the cached lobby', () => { - setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false }); + setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', 'pending')); - expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']); + expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']); }); it('replaces a pending invitation already in the list (e.g. an updated response)', () => { - setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false }); + setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false }); const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 }; patchLobbyInvitation(updated); - expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']); - expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999); + expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']); + expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999); }); it('removes an invitation that reached a terminal status', () => { for (const terminal of ['declined', 'cancelled', 'started', 'expired']) { - setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false }); + setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', terminal)); - expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']); + expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']); } }); it('is a no-op for a terminal invitation that is not in the list', () => { - setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false }); + setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', 'declined')); - expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']); + expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']); }); it('is a no-op when there is no cached lobby yet', () => { patchLobbyInvitation(invitation('i1', 'pending')); - expect(getLobby()).toBeNull(); + expect(getLobby(false)).toBeNull(); }); it('preserves games and incoming when patching an invitation', () => { const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; - setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false }); + setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false, offline: false }); patchLobbyInvitation(invitation('i1', 'pending')); - expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']); - expect(getLobby()?.incoming).toEqual(incoming); + expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']); + expect(getLobby(false)?.incoming).toEqual(incoming); + }); +}); + +describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => { + it('returns the snapshot only for the mode it was stored under', () => { + setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false }); + expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']); + // Reading it as the OTHER (offline) mode must not surface the online snapshot. + expect(getLobby(true)).toBeNull(); + }); + + it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => { + setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false }); + setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true }); + expect(getLobby(false)).toBeNull(); + expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']); }); }); diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts index 5e2ca4e..e6f0e41 100644 --- a/ui/src/lib/lobbycache.ts +++ b/ui/src/lib/lobbycache.ts @@ -13,13 +13,18 @@ interface LobbySnapshot { // atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last // known enabled/disabled state instantly (no flicker), then refreshes it in the background. atGameLimit: boolean; + // Which mode the snapshot belongs to (offline = device-local games, online = server games). The + // lobby renders it instantly only when it matches the current mode, so a mode flip never flashes — + // or lets the player open — the other mode's games before the background refresh replaces them. + offline: boolean; } let snapshot: LobbySnapshot | null = null; -/** getLobby returns the last lobby lists, or null before the first load. */ -export function getLobby(): LobbySnapshot | null { - return snapshot; +/** getLobby returns the last lobby lists for the given mode, or null before the first load or when + * the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */ +export function getLobby(offline: boolean): LobbySnapshot | null { + return snapshot && snapshot.offline === offline ? snapshot : null; } /** setLobby stores the latest lobby lists. */ diff --git a/ui/src/lib/localgame/source.test.ts b/ui/src/lib/localgame/source.test.ts index c9b4f94..b7c1891 100644 --- a/ui/src/lib/localgame/source.test.ts +++ b/ui/src/lib/localgame/source.test.ts @@ -13,6 +13,7 @@ vi.mock('../dict', () => ({ import { LocalSource, isLocalGameId } from './source'; import type { Seat } from './serialize'; +import { hasAlphabet, alphabetValues } from '../alphabet'; const seats: Seat[] = [ { kind: 'human', name: 'You' }, @@ -42,6 +43,18 @@ describe('LocalSource', () => { expect(st.game.toMove).toBe(0); }); + it('seeds the per-variant alphabet from the ruleset, so offline tiles show real weights not zeros', async () => { + // Offline there is no server to send the alphabet table the rack/board render from; opening a + // local game must seed it from the static ruleset. Without this a cold offline boot draws zeros. + const src = await newSource('local:gAlpha', 42n); + await src.gameState('local:gAlpha'); + expect(hasAlphabet('scrabble_en')).toBe(true); + const values = alphabetValues('scrabble_en'); + expect(values.length).toBe(26); + expect(values[0]).toBe(1); // A = 1 (pinned to rules.go / ruleset.ts), not 0 + expect(values.every((v) => v > 0)).toBe(true); + }); + it('replies to a human pass with a synchronous robot move, delivered via the event', async () => { const src = await newSource('local:g2', 999n); const events: PushEvent[] = []; diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 42fd181..ba58106 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -13,6 +13,7 @@ import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './store'; import { RULESETS } from './ruleset'; import { getDawg } from '../dict'; +import { setAlphabet, hasAlphabet } from '../alphabet'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { decide } from '../robot/strategy'; import { GatewayError, type PlacedTile } from '../client'; @@ -345,8 +346,22 @@ export class LocalSource implements GameLoopSource { return { game: this.gameView(entry), seat, rack, bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0 }; } + // Offline there is no server to send the per-variant alphabet table (letters + tile values) the + // rack and board render from (lib/alphabet); seed it from the static offline ruleset so a cold- + // booted local game shows real tile weights instead of zeros. Idempotent and cheap — a no-op once + // the table is cached (from here or a prior online session). + private ensureAlphabet(variant: Variant): void { + if (hasAlphabet(variant)) return; + const rs = RULESETS[variant]; + setAlphabet( + variant, + rs.letters.map((letter, index) => ({ index, letter, value: rs.values[index] })), + ); + } + private gameView(entry: Live): GameView { const { game, record } = entry; + this.ensureAlphabet(record.variant); const seats: Seat[] = record.seats.map((s, i) => ({ seat: i, accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 1cd9405..6ba4fbe 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -34,35 +34,47 @@ // operator feedback reply (the Settings → Info badge folds in here). const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0)); + // A generation guard so only the LATEST load() writes the module lists. Three triggers (onMount, a + // game event, the offline-mode flip) can overlap, and a slow online gamesList() completing after a + // fast offline list() (or vice versa) would otherwise leave the other mode's games on screen — the + // "wrong game in the lobby after a toggle" bug. A superseded run bails at the checks below. + let loadSeq = 0; async function load() { + const seq = ++loadSeq; // Deliberate offline mode: never touch the network. The lobby shows only the device-local // vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online // games, invitations or incoming requests to fetch. if (offlineMode.active) { - games = await localSource.list(); + const local = await localSource.list(); + if (seq !== loadSeq) return; + games = local; invitations = []; incoming = []; atGameLimit = false; app.notifications = 0; - setLobby({ games, invitations, incoming, atGameLimit }); + setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active }); app.lobbyReady = true; return; } try { const list = await gateway.gamesList(); + if (seq !== loadSeq) return; games = list.games; // Seed the per-game unread badges from the authoritative list. The live stream only raises // unread (it never seeds it from a GameView), so a persisted unread survives a reload here. for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages); atGameLimit = list.atGameLimit; if (!guest) { - [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); + const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); + if (seq !== loadSeq) return; + invitations = inv; + incoming = inc; // The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply; // invitations surface in their own lobby section above. app.notifications = incoming.length; void refreshFeedbackBadge(); } - setLobby({ games, invitations, incoming, atGameLimit }); + setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active }); // Warm the cache for the ongoing games so opening one from the lobby is instant. The list // just loaded, so the connection is up; the call is non-blocking and re-running it on each // lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped). @@ -85,7 +97,7 @@ onMount(() => { // Render instantly from the cached lists (so the screen does not "draw in" during // the back-slide), then refresh in the background. - const cached = getLobby(); + const cached = getLobby(offlineMode.active); if (cached) { games = cached.games; invitations = cached.invitations; @@ -214,7 +226,7 @@ revealedId = null; const prev = games; games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out - setLobby({ games, invitations, incoming, atGameLimit }); + setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active }); try { // A local (offline) game is deleted from the device store; an online game is hidden on the // backend. Routing by id keeps the delete off the network for a local game (the transport @@ -223,7 +235,7 @@ else await gateway.hideGame(id); } catch (e) { games = prev; - setLobby({ games, invitations, incoming, atGameLimit }); + setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active }); handleError(e); } }