fix(offline): zero tile weights on cold boot + wrong-mode game in the lobby
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 1m29s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s

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.
This commit is contained in:
Ilia Denisov
2026-07-06 23:11:17 +02:00
parent 08792dad3d
commit 359af83c34
5 changed files with 95 additions and 34 deletions
+13
View File
@@ -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[] = [];
+15
View File
@@ -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}`,