release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212

Merged
developer merged 79 commits from development into master 2026-07-07 14:40:43 +00:00
5 changed files with 95 additions and 34 deletions
Showing only changes of commit 359af83c34 - Show all commits
+40 -24
View File
@@ -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 modes 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']);
});
});
+8 -3
View File
@@ -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
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}`,
+19 -7
View File
@@ -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);
}
}