# Per-game state store This document describes the per-game state owned by the in-game shell layout. The store serves the header turn counter, the map view, inspector tabs, the order composer, and the calculator. ## Lifecycle `routes/games/[id]/+layout.svelte` instantiates one `GameStateStore` per game (the layout remounts when the user navigates to a different game id, so each game gets a fresh store). The layout exposes the instance through Svelte context under `GAME_STATE_CONTEXT_KEY`; descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`. The layout's `onMount` builds the `GalaxyClient`, loads `Cache` through `loadStore()`, then calls `gameState.init({ client, cache, gameId })`. `init`: 1. installs a `visibilitychange` listener on `document` so the report is refreshed when the tab regains focus; 2. calls `setGame(gameId)`, which: - reads the per-game wrap-mode preference from `Cache` (`game-prefs / /wrap-mode`, default `torus`); - calls `lobby.my.games.list` and finds the game record (`GameSummary` carries `current_turn`); if the user is not a member, the store flips to `error`; - calls `user.games.report` for the discovered turn and decodes the FlatBuffers response into a TS-friendly `GameReport` shape. The store exposes: | field | type | meaning | | ------------- | ----------------------------- | ---------------------------------------------------- | | `gameId` | `string` | active game id | | `status` | `idle / loading / ready / error` | current lifecycle state | | `report` | `GameReport \| null` | latest decoded report, `null` until first fetch | | `currentTurn` | `number` | server's authoritative current turn (live snapshot) | | `viewedTurn` | `number` | turn whose snapshot is in `report`; equals `currentTurn` in live mode | | `historyMode` | `boolean` (derived) | true while `status === "ready"` and `viewedTurn < currentTurn` | | `pendingTurn` | `number \| null` | latest server turn the user has not yet opened | | `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | | `error` | `string \| null` | localised error message when `status === "error"` | ## Store extensions `GameReport` and `decodeReport` are extended as each slice of the wire lands (ships, fleets, sciences, routes, battles, mail). `currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` / `returnToCurrent()` handle history navigation. The derived `historyMode` rune flips automatically when `viewedTurn < currentTurn`; the layout passes it to the sidebar / bottom-tabs wiring (which hides the order tab) and to `OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`). See "History mode" below for the cache and refresh rules. Tab-focus refreshes are supplemented by push-event-driven refreshes; the visibility listener stays as a fallback for background tabs that miss a push. The wrap-mode toggle UI is wired on top of `setWrapMode`. ## Why `current_turn` lives on `GameSummary` The user-facing surface needs the current turn number to know which report to fetch. Two alternatives were rejected: - a brand-new `user.games.state` message — adds a full wire-flow (fbs schema, transcoder, gateway routing, backend handler) for a one-field response; - hard-coding `turn=0` for all games — works for the dev sandbox (which never advances past turn zero) but renders the initial state for any real game past turn zero. Extending `GameSummary` reuses the existing lobby pipeline; the backend already tracks `current_turn` in its runtime projection (`backend/internal/server/handlers_user_lobby_helpers.go` `gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`). The `current_turn` field defaults to zero on the FB side, so existing tests and the dev sandbox flow continue to work unchanged. ## State binding `map/state-binding.ts::reportToWorld(report)` translates a `GameReport` into a renderer-ready `World`. It emits one Point primitive per planet across all four kinds (local / other / uninhabited / unidentified). Each kind gets a distinct fill colour, fill alpha, and point radius so the four classes are visually-distinguishable at a glance; colour-palette refinement is deferred to the finalization plan ([../PLAN-finalize.md](../PLAN-finalize.md)). The planet engine number is reused as the primitive id so a hit-test result can resolve back to a planet without an extra lookup table. ## Refresh discipline `refresh()` re-fetches the current-turn snapshot. It is called by the `visibilitychange` handler when `document.visibilityState === "visible"` and the store is already in `ready` state. The map view's mount effect skips a re-render when the new snapshot's turn matches the previously-mounted turn (and the wrap mode is unchanged), so a no-op refresh does not flicker the canvas. In history mode `refresh()` is a no-op — forcing a reload would silently bump the user back onto the current turn while they are intentionally viewing a past one. Push events still deliver new-turn notifications asynchronously while the user explores history, so the pending-turn toast continues to work. `setWrapMode(mode)` writes to `Cache` and updates the rune; the map view's effect picks the change up and re-mounts the renderer with the new mode. ## Map visibility toggles A `mapToggles: MapToggles` rune drives the gear popover in the map view. Every flag defaults to `true` — including `unreachablePlanets` (showing every planet by default) and `visibleHyperspace` (the fog overlay on by default). The exhaustive shape lives in `src/lib/game-state.svelte.ts`; the gear popover (`src/lib/active-view/map-toggles.svelte`) is a thin view of the rune. `setMapToggle(key, value)` flips one entry in place and persists the whole blob to `Cache` under the `game-map-toggles/{gameId}` key. The blob carries a companion `lastResetTurn` number — the turn at which the toggles were last reset to defaults — so the new-turn reset path (below) can detect a stale blob even across a cross-session gap. ### New-turn reset A new server-side turn force-resets every toggle to defaults so a hidden category never makes the player miss what changed: - `setGame` reads the persisted `{toggles, lastResetTurn}` blob. If `lastResetTurn < currentTurn`, the rune is overwritten with `DEFAULT_MAP_TOGGLES` and the blob is rewritten with `lastResetTurn = currentTurn` before the report load. Otherwise the persisted overrides are restored. - `advanceToPending` (the user's explicit jump onto the new turn) calls the same reset path after `loadTurn(currentTurn, …)` succeeds, updating `lastResetTurn` to the freshly-loaded current turn. - `viewTurn` (history mode) does NOT reset — toggles are a single shared state per game, not per turn. - `refresh()` does not advance turns, so it does not reset. The cache namespace and blob shape are documented in `storage.md`. ## History mode The store lets the user step backward through the report timeline without losing the live snapshot. The store keeps two turn runes: - `currentTurn` — the server's authoritative latest. Only `setGame` and `advanceToPending` write to it. - `viewedTurn` — the turn currently rendered. `viewTurn(N)` flips this rune and the underlying `report` to `N` without touching `currentTurn`. `returnToCurrent()` is a one-line wrapper that navigates back to live. The derived `historyMode` rune (`status === "ready" && viewedTurn < currentTurn`) drives every history-aware consumer: - the layout passes it to `Sidebar` / `BottomTabs` so the order tab vanishes; - the layout passes a `getHistoryMode` getter to `OrderDraftStore.bindClient` so `add` / `remove` / `move` are no-ops while the user is looking at a past turn; - `RenderedReportSource` returns the raw report (no order overlay) because the draft is composed against the current turn; - the new `HistoryBanner` component renders the sticky "Viewing turn N · read-only" strip when the flag is true. `last-viewed-turn` means "the latest turn the user was caught up on". `loadTurn` only writes the cache row when called with `isCurrent === true` (i.e. when the load matches `currentTurn`). Historical excursions are therefore ephemeral: closing the tab and reopening the game resumes on the last caught-up turn, not on the last clicked one. Past-turn reports are cached in the `game-history` namespace (`{gameId}/turn/{N}` → `GameReport`). The cache is written by `loadTurn` on every successful historical fetch and read first by `viewTurn(N)` before falling back to the network. Past turns are immutable, so the cache has no TTL and no eviction. The current-turn snapshot is deliberately *not* cached — it is mutable until the next engine tick.