// Per-game runtime state owned by the in-game shell layout // (`routes/games/[id]/+layout.svelte`). The store discovers the // game's current turn through `lobby.my.games.list`, fetches the // matching `user.games.report`, and exposes a TS-friendly `GameReport` // snapshot to every consumer (header turn counter, map view, // inspector tabs in later phases). // // Phase 11 covers planets only; later phases extend the report // surface as their slice of state lands. Every consumer reads from // the same store instance — instantiation per game guarantees the // layout remount on `gameId` change reseeds the snapshot, while // navigation between active views inside the same game keeps the // instance alive (state-preservation rule, see ui/docs/navigation.md). import { GameStateError, fetchGameReport, type GameReport, } from "../api/game-state"; import { listMyGames, type GameSummary } from "../api/lobby"; import type { GalaxyClient } from "../api/galaxy-client"; import type { Cache } from "../platform/store/index"; import type { WrapMode } from "../map/world"; const PREF_NAMESPACE = "game-prefs"; const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`; const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) => `${gameId}/last-viewed-turn`; const HISTORY_NAMESPACE = "game-history"; const HISTORY_KEY_TURN = (gameId: string, turn: number) => `${gameId}/turn/${turn}`; /** * GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell * layout uses to expose its `GameStateStore` instance to descendants. * Header / map / inspector children resolve the store via * `getContext(GAME_STATE_CONTEXT_KEY)`. */ export const GAME_STATE_CONTEXT_KEY = Symbol("game-state"); type Status = "idle" | "loading" | "ready" | "error"; export class GameStateStore { gameId: string = $state(""); /** * gameName mirrors the lobby's `game_name` for the running game. * Lifted from the lobby record on `setGame`; empty during boot * and set once the lobby query resolves. Used by the header to * compose the ` @ , turn N` display. */ gameName: string = $state(""); status: Status = $state("idle"); report: GameReport | null = $state(null); wrapMode: WrapMode = $state("torus"); error: string | null = $state(null); /** * currentTurn mirrors the engine's turn number for the running * game (lifted from the lobby record on `setGame`). Phase 14 * exposes it so the layout can pass it to * `OrderDraftStore.hydrateFromServer` after both stores boot; * Phase 26 keeps the "authoritative server-side turn" meaning — * only `setGame`, `advanceToPending`, and the visibility-listener * lobby re-query update it. History navigation (`viewTurn`) leaves * it alone so the "Return to current turn" affordance keeps a * reliable target. */ currentTurn = $state(0); /** * viewedTurn is the turn whose snapshot is currently displayed. * In live mode it equals `currentTurn`. Phase 26 history mode * decouples the two: `viewTurn(N)` flips this rune (and `report`) * to N without touching `currentTurn` or `last-viewed-turn`. */ viewedTurn = $state(0); /** * historyMode is the derived "user is viewing a past turn" rune * consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26 * history banner, the rendered-report overlay short-circuit, and * the order-draft mutation gate. It depends only on the rune state * above, so every consumer reacts to a single source of truth. */ historyMode = $derived( this.status === "ready" && this.viewedTurn < this.currentTurn, ); /** * synthetic is set by `initSynthetic` for DEV-only sessions backed * by a hand-loaded report (lobby's "Load synthetic report" * affordance). The flag travels through the layout so the order * tab and any future server-bound features can short-circuit and * stay local. The auto-sync pipeline already protects itself via * the UUID guard on `OrderDraftStore.scheduleSync`, so flipping * this flag is enough to keep the network silent. */ synthetic = $state(false); /** * pendingTurn carries the latest server-side turn the user has not * yet opened: it is `> currentTurn` whenever the server reports a * new turn (either through a `game.turn.ready` push event after * boot, or through the boot-time discovery that the persisted * `lastViewedTurn` is behind the lobby's `current_turn`). The * layout's `$effect` renders a toast/banner when it is non-null; * `advanceToPending()` refreshes the store onto the new turn and * clears the rune. */ pendingTurn: number | null = $state(null); private client: GalaxyClient | null = null; private cache: Cache | null = null; private destroyed = false; private visibilityListener: (() => void) | null = null; /** * init kicks off the per-game lifecycle. The call is idempotent on * the same `gameId`; calling with a different game forwards through * `setGame` so the layout can hand off across navigations. */ async init(opts: { client: GalaxyClient; cache: Cache; gameId: string; }): Promise { this.client = opts.client; this.cache = opts.cache; await this.setGame(opts.gameId); this.installVisibilityListener(); } /** * setGame switches the store to the supplied game id, fetches the * matching lobby record to discover `current_turn`, then loads the * report. Failure paths surface through `status === "error"` and * the matching `error` string (already localised by the caller). */ async setGame(gameId: string): Promise { if (this.client === null || this.cache === null) { throw new Error("game-state: setGame called before init"); } // Only forget the pending indicator when the consumer is // actually switching games. On the initial `setGame` after // `init` the previous `gameId` is the empty string, and a // concurrent `markPendingTurn` from a push event arriving // while we were still bootstrapping must not be erased. if (this.gameId !== "" && this.gameId !== gameId) { this.pendingTurn = null; } this.gameId = gameId; this.status = "loading"; this.error = null; this.report = null; this.wrapMode = await readWrapMode(this.cache, gameId); const lastViewed = await readLastViewedTurn(this.cache, gameId); try { const summary = await this.findGame(gameId); if (summary === null) { this.status = "error"; this.error = `game ${gameId} is not in your list`; return; } this.gameName = summary.gameName; this.currentTurn = summary.currentTurn; // If the persisted last-viewed turn is older than the // server-side current turn, open the user on their last-seen // snapshot and surface the gap through `pendingTurn` so the // shell can render a "new turn available" affordance instead // of silently auto-advancing. After Phase 26 the same gap // also flips `historyMode` to true (viewedTurn < currentTurn), // so the read-only banner appears alongside the toast. if ( lastViewed !== null && lastViewed >= 0 && lastViewed < summary.currentTurn ) { this.pendingTurn = summary.currentTurn; await this.loadTurn(lastViewed, { isCurrent: false }); } else { await this.loadTurn(summary.currentTurn, { isCurrent: true }); } } catch (err) { if (this.destroyed) return; this.status = "error"; this.error = describe(err); } } /** * markPendingTurn records a server-reported new turn (typically * delivered through `game.turn.ready`). Values that are not * strictly ahead of the latest known turn (current or already * pending) are ignored so a replayed event cannot regress the * indicator. */ markPendingTurn(turn: number): void { const latest = this.pendingTurn ?? this.currentTurn; if (turn > latest) { this.pendingTurn = turn; } } /** * advanceToPending re-queries the lobby record and loads the * report at the server's latest `current_turn`, then clears the * pending indicator. Unlike `setGame`, this skips the * `lastViewedTurn` lookup — the user has explicitly asked to * jump to the new turn, so any persisted bookmark from the * previous session is irrelevant. Failures keep the indicator * set so the user can retry from the same affordance. */ async advanceToPending(): Promise { if (this.pendingTurn === null || this.client === null) { return; } this.status = "loading"; this.error = null; try { const summary = await this.findGame(this.gameId); if (summary === null) { this.status = "error"; this.error = `game ${this.gameId} is not in your list`; return; } this.gameName = summary.gameName; this.currentTurn = summary.currentTurn; await this.loadTurn(summary.currentTurn, { isCurrent: true }); this.pendingTurn = null; } catch (err) { if (this.destroyed) return; this.status = "error"; this.error = describe(err); } } /** * viewTurn loads the historical snapshot for `turn` and switches the * UI into history mode (Phase 26). The current turn is untouched — * `historyMode` flips on automatically through the derived rune, and * the `last-viewed-turn` cache is only refreshed when the caller * happens to ask for the currentTurn (e.g. `returnToCurrent`). A * cache hit on `game-history/{gameId}/turn/{N}` skips the network; * past turns are immutable so the cache never goes stale. */ async viewTurn(turn: number): Promise { if (this.client === null) return; if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) { return; } this.status = "loading"; this.error = null; try { await this.loadTurn(turn, { isCurrent: turn === this.currentTurn }); } catch (err) { if (this.destroyed) return; this.status = "error"; this.error = describe(err); } } /** * returnToCurrent jumps back to the server's current turn after a * history excursion. Thin wrapper around `viewTurn(currentTurn)` so * the banner / popover share the same call site. */ returnToCurrent(): Promise { return this.viewTurn(this.currentTurn); } /** * refresh is fired from the `visibilitychange` listener. In live * mode it re-fetches the report at the current turn so the map and * the counter catch up after the user returns to the tab. In * history mode it is a no-op: the user is intentionally viewing a * past turn, push events (Phase 24) deliver new-turn notifications * asynchronously, and forcing a reload would silently bump the * user out of history mode. */ async refresh(): Promise { if (this.client === null) return; if (this.historyMode) return; try { await this.loadTurn(this.currentTurn, { isCurrent: true }); } catch (err) { if (this.destroyed) return; console.warn("game-state: refresh failed", err); } } /** * setWrapMode persists the per-game preference into Cache so the * next visit to the game restores it. Phase 29 wires the toggle UI; * Phase 11 only reads through `wrapMode` and writes via this method. */ async setWrapMode(mode: WrapMode): Promise { this.wrapMode = mode; if (this.cache !== null) { await this.cache.put(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(this.gameId), mode); } } /** * failBootstrap is used by the layout to surface errors that * happen *before* `init` could be reached (missing keypair, missing * gateway public key, core/store load failure). It does not need * `init` to have run first. */ failBootstrap(message: string): void { this.status = "error"; this.error = message; } /** * initSynthetic seeds the store from a pre-loaded `GameReport` * without touching the network. Used by the lobby's DEV-only * "Load synthetic report" affordance: the layout invokes this * instead of `init` when the route id is in the synthetic id * range. The store ends up in `ready` immediately; no polling, * no visibility-driven refresh, no client / cache-of-server * binding. */ async initSynthetic(opts: { cache: Cache; gameId: string; report: GameReport; }): Promise { this.cache = opts.cache; this.gameId = opts.gameId; this.synthetic = true; this.gameName = "Synthetic"; this.error = null; this.wrapMode = await readWrapMode(opts.cache, opts.gameId); this.report = opts.report; this.currentTurn = opts.report.turn; this.viewedTurn = opts.report.turn; this.status = "ready"; } dispose(): void { this.destroyed = true; if (this.visibilityListener !== null && typeof document !== "undefined") { document.removeEventListener("visibilitychange", this.visibilityListener); } this.visibilityListener = null; this.client = null; this.cache = null; } private async findGame(gameId: string): Promise { if (this.client === null) return null; const games = await listMyGames(this.client); return games.find((g) => g.gameId === gameId) ?? null; } private async loadTurn( turn: number, opts: { isCurrent: boolean }, ): Promise { if (this.client === null) return; const report = await this.readReport(turn, opts.isCurrent); if (this.destroyed) return; this.report = report; this.viewedTurn = turn; this.status = "ready"; if (this.cache === null) return; if (opts.isCurrent) { // Persist last-viewed-turn only when the user is caught up // on the live snapshot. Historical excursions are ephemeral // (Phase 26 decision): the resume-on-open affordance from // Phase 11 must keep meaning "the latest turn this player // was caught up on", not "wherever they last clicked". await this.cache.put( PREF_NAMESPACE, PREF_KEY_LAST_VIEWED_TURN(this.gameId), turn, ); return; } // Past turns are immutable, so the snapshot is safe to cache // for fast re-entry. The current-turn snapshot deliberately // skips the cache — it is mutable until the next tick. await this.cache.put( HISTORY_NAMESPACE, HISTORY_KEY_TURN(this.gameId, turn), report, ); } private async readReport( turn: number, isCurrent: boolean, ): Promise { if (this.client === null) { throw new Error("game-state: readReport called without client"); } if (!isCurrent && this.cache !== null) { const cached = await this.cache.get( HISTORY_NAMESPACE, HISTORY_KEY_TURN(this.gameId, turn), ); if (cached !== undefined && cached.turn === turn) { return cached; } } return await fetchGameReport(this.client, this.gameId, turn); } private installVisibilityListener(): void { if (typeof document === "undefined") return; const listener = (): void => { if (document.visibilityState === "visible" && this.status === "ready") { void this.refresh(); } }; this.visibilityListener = listener; document.addEventListener("visibilitychange", listener); } } async function readWrapMode(cache: Cache, gameId: string): Promise { const stored = await cache.get(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(gameId)); if (stored === "no-wrap") return "no-wrap"; return "torus"; } async function readLastViewedTurn( cache: Cache, gameId: string, ): Promise { const stored = await cache.get( PREF_NAMESPACE, PREF_KEY_LAST_VIEWED_TURN(gameId), ); if (typeof stored !== "number" || !Number.isFinite(stored)) { return null; } return stored; } function describe(err: unknown): string { if (err instanceof GameStateError) { return err.message; } if (err instanceof Error) { return err.message; } return "request failed"; }