From 3a72bc29badc4dd9d0923abd3562d3f1a5f7d0ae Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 16:59:36 +0200 Subject: [PATCH] fix(offline): delete a finished local game from the device, not the network The lobby's finished-game delete (hide) always called gateway.hideGame; for a local (offline) game the transport kill switch refused it, so it toasted and the game stayed. Route by id: a local game is removed from the device store + the source cache (LocalSource.delete), an online game still hides on the backend. --- ui/src/lib/gamesource.ts | 3 ++- ui/src/lib/localgame/source.list.test.ts | 8 ++++++++ ui/src/lib/localgame/source.ts | 10 +++++++++- ui/src/screens/Lobby.svelte | 8 ++++++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts index e35bb38..201ffbe 100644 --- a/ui/src/lib/gamesource.ts +++ b/ui/src/lib/gamesource.ts @@ -40,6 +40,7 @@ export const localSource = { draftSave: (_id, _json) => load().then((s) => s.draftSave()), create: (opts) => load().then((s) => s.create(opts)), 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 // the real subscription once the engine loads and, until then, cancels a pending subscribe. events: (id, onEvent) => { @@ -53,7 +54,7 @@ export const localSource = { unsub(); }; }, -} satisfies GameLoopSource & Pick; +} satisfies GameLoopSource & Pick; /** * gameSource returns the source that runs the game with the given id: the local engine for a local diff --git a/ui/src/lib/localgame/source.list.test.ts b/ui/src/lib/localgame/source.list.test.ts index df620b0..2b64ffb 100644 --- a/ui/src/lib/localgame/source.list.test.ts +++ b/ui/src/lib/localgame/source.list.test.ts @@ -65,6 +65,14 @@ describe('LocalSource.list', () => { 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 () => { // 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)); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 3c8f848..42fd181 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -10,7 +10,7 @@ import { LocalGame, type LocalMove } from './engine'; 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 { getDawg } from '../dict'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; @@ -151,6 +151,14 @@ export class LocalSource implements GameLoopSource { 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 { + this.live.delete(gameId); + this.listeners.delete(gameId); + await deleteLocalGame(gameId); + } + async gameState(gameId: string): Promise { return this.stateView(await this.load(gameId)); } diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 280cbd3..1cd9405 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -14,7 +14,7 @@ import { getLobby, setLobby } from '../lib/lobbycache'; import { preloadGames } from '../lib/preload'; 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 type { AccountRef, GameView, Invitation } from '../lib/model'; 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 setLobby({ games, invitations, incoming, atGameLimit }); 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) { games = prev; setLobby({ games, invitations, incoming, atGameLimit });