diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 3a7b4ae..dd55432 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -118,6 +118,17 @@ export class GameStateStore { */ mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES }); error: string | null = $state(null); + /** + * notFound is the distinct "this game is not in the player's list" + * signal, set when `findGame` returns null (cancelled, removed, or + * access revoked). It is a clean flag the app-shell reads after + * `init` to drop a restored/stale game back to the lobby with a + * toast, rather than string-matching the `error` message. Transient + * network failures keep `notFound` false (they take the catch path) + * so they still surface the in-game error state for a retry. Reset + * to false at the start of every `setGame` / `advanceToPending`. + */ + notFound = $state(false); /** * currentTurn mirrors the engine's turn number for the running * game (lifted from the lobby record on `setGame`). Phase 14 @@ -218,6 +229,7 @@ export class GameStateStore { this.gameId = gameId; this.status = "loading"; this.error = null; + this.notFound = false; this.report = null; this.wrapMode = await readWrapMode(this.cache, gameId); @@ -229,6 +241,7 @@ export class GameStateStore { if (summary === null) { this.status = "error"; this.error = `game ${gameId} is not in your list`; + this.notFound = true; return; } this.gameName = summary.gameName; @@ -306,11 +319,13 @@ export class GameStateStore { } this.status = "loading"; this.error = null; + this.notFound = false; try { const summary = await this.findGame(this.gameId); if (summary === null) { this.status = "error"; this.error = `game ${this.gameId} is not in your list`; + this.notFound = true; return; } this.gameName = summary.gameName; @@ -558,6 +573,18 @@ export class GameStateStore { private installVisibilityListener(): void { if (typeof document === "undefined") return; + // Idempotent on re-init. In the single-URL app-shell a direct + // game → game switch (a push deep-link or a refresh-restore) + // re-runs `init` without unmounting the shell, so a naive + // `addEventListener` here would stack a second listener on every + // switch. Drop any previously-registered one first so exactly one + // stays live. + if (this.visibilityListener !== null) { + document.removeEventListener( + "visibilitychange", + this.visibilityListener, + ); + } const listener = (): void => { if (document.visibilityState === "visible" && this.status === "ready") { void this.refresh(); diff --git a/ui/frontend/src/lib/game/game-shell.svelte b/ui/frontend/src/lib/game/game-shell.svelte index 4eadcd9..5e1da80 100644 --- a/ui/frontend/src/lib/game/game-shell.svelte +++ b/ui/frontend/src/lib/game/game-shell.svelte @@ -515,6 +515,25 @@ return to the lobby still disposes the stores via `onDestroy`. orderDraft.init({ cache, gameId: activeGameId }), mailStore.init({ client, cache, gameId: activeGameId }), ]); + // A restored or stale game id may point at a game that is + // no longer in the player's list (cancelled, removed, or + // access revoked). `init` flags that distinct case via + // `gameState.notFound` (a transient network error keeps it + // false and surfaces the in-game error state instead). Drop + // to the lobby with a toast rather than stranding the user + // on the in-game "not in your list" error. Leaving the + // `game` screen unmounts the shell, so the stores are + // disposed via `onDestroy`; the rest of the bootstrap + // (client bind, server hydration) is skipped for the dead + // game. + if (gameState.notFound) { + appScreen.go("lobby"); + toast.show({ + messageKey: "game.events.unavailable.message", + durationMs: 8000, + }); + return; + } galaxyClient.set(client); orderDraft.bindClient(client, { getCurrentTurn: () => gameState.currentTurn, diff --git a/ui/frontend/src/lib/header/header.svelte b/ui/frontend/src/lib/header/header.svelte index 0fa3905..b1dd0f5 100644 --- a/ui/frontend/src/lib/header/header.svelte +++ b/ui/frontend/src/lib/header/header.svelte @@ -18,6 +18,7 @@ absent until Phase 24 wires push-event state.