Merge pull request 'fix(offline): delete a finished local game from the device, not the network' (#201) from feature/offline-delete-game into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m38s

This commit was merged in pull request #201.
This commit is contained in:
2026-07-06 15:06:17 +00:00
4 changed files with 25 additions and 4 deletions
+2 -1
View File
@@ -40,6 +40,7 @@ export const localSource = {
draftSave: (_id, _json) => load().then((s) => s.draftSave()), draftSave: (_id, _json) => load().then((s) => s.draftSave()),
create: (opts) => load().then((s) => s.create(opts)), create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()), list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)),
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires // events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
// the real subscription once the engine loads and, until then, cancels a pending subscribe. // the real subscription once the engine loads and, until then, cancels a pending subscribe.
events: (id, onEvent) => { events: (id, onEvent) => {
@@ -53,7 +54,7 @@ export const localSource = {
unsub(); unsub();
}; };
}, },
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list'>; } satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list' | 'delete'>;
/** /**
* gameSource returns the source that runs the game with the given id: the local engine for a local * gameSource returns the source that runs the game with the given id: the local engine for a local
+8
View File
@@ -65,6 +65,14 @@ describe('LocalSource.list', () => {
expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id
}); });
it('delete removes a stored local game from the list', async () => {
const src = new LocalSource();
await src.create(opts('local:x', 5n));
expect((await src.list()).map((g) => g.id)).toContain('local:x');
await src.delete('local:x');
expect((await src.list()).map((g) => g.id)).not.toContain('local:x');
});
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => { it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty. // Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
await new LocalSource().create(opts('local:old', 7n)); await new LocalSource().create(opts('local:old', 7n));
+9 -1
View File
@@ -10,7 +10,7 @@
import { LocalGame, type LocalMove } from './engine'; import { LocalGame, type LocalMove } from './engine';
import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize'; import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize';
import { getLocalGame, saveLocalGame, listLocalGames } from './store'; import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './store';
import { RULESETS } from './ruleset'; import { RULESETS } from './ruleset';
import { getDawg } from '../dict'; import { getDawg } from '../dict';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
@@ -151,6 +151,14 @@ export class LocalSource implements GameLoopSource {
return views; return views;
} }
/** delete removes a local game from the store and the in-memory cache — the offline lobby's
* finished-game delete. Best-effort: a failed store delete still drops it from this session. */
async delete(gameId: string): Promise<void> {
this.live.delete(gameId);
this.listeners.delete(gameId);
await deleteLocalGame(gameId);
}
async gameState(gameId: string): Promise<StateView> { async gameState(gameId: string): Promise<StateView> {
return this.stateView(await this.load(gameId)); return this.stateView(await this.load(gameId));
} }
+6 -2
View File
@@ -14,7 +14,7 @@
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { kickDictPreload, offlineMode } from '../lib/offline.svelte'; import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
import { localSource } from '../lib/gamesource'; import { localSource, isLocalGameId } from '../lib/gamesource';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -216,7 +216,11 @@
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming, atGameLimit }); setLobby({ games, invitations, incoming, atGameLimit });
try { try {
await gateway.hideGame(id); // 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
// kill switch would otherwise refuse it offline).
if (isLocalGameId(id)) await localSource.delete(id);
else await gateway.hideGame(id);
} catch (e) { } catch (e) {
games = prev; games = prev;
setLobby({ games, invitations, incoming, atGameLimit }); setLobby({ games, invitations, incoming, atGameLimit });