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
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user