Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
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
The in-game shell (lib/game/game-shell.svelte) instantiates one
GameStateStore per game. The shell is mounted by the single-route
dispatcher only while appScreen.screen === "game", and remounts when
appScreen.gameId changes, so each game gets a fresh store. The shell
exposes the instance through Svelte context under
GAME_STATE_CONTEXT_KEY; descendants read it via
getContext(GAME_STATE_CONTEXT_KEY).
The shell's boot effect builds the GalaxyClient, loads Cache
through loadStore(), then calls gameState.init({ client, cache, gameId }). init:
- installs a
visibilitychangelistener ondocumentso the report is refreshed when the tab regains focus; - calls
setGame(gameId), which:- reads the per-game wrap-mode preference from
Cache(game-prefs / <gameId>/wrap-mode, defaulttorus); - calls
lobby.my.games.list(findGame) and finds the game record (GameSummarycarriescurrent_turn); if the game is not in the player's list, the store sets thenotFoundflag (see below); - calls
user.games.reportfor the discovered turn and decodes the FlatBuffers response into a TS-friendlyGameReportshape.
- reads the per-game wrap-mode preference from
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" |
notFound |
boolean |
true when the game is not in the player's list (cancelled / removed / access revoked); the shell drops to the lobby |
Missing or inaccessible game
A restored or stale game id (a sessionStorage snapshot pointing at a
game that was cancelled, removed, or whose access was revoked) is a
distinct case from a transient failure. When findGame returns no
matching record, setGame sets the boolean notFound flag rather
than synthesising an error message. After init resolves, the in-game
shell reads gameState.notFound and, when true, calls
appScreen.go("lobby") and shows a game.events.unavailable toast —
the player lands back in the lobby instead of on an in-game error
screen. A transient network failure takes the catch path instead,
leaving notFound false and flipping status to error so the
in-game error state offers a retry. notFound resets to false at the
start of every setGame / advanceToPending. See
navigation.md for the restore-and-validate flow.
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 shell 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.statemessage — adds a full wire-flow (fbs schema, transcoder, gateway routing, backend handler) for a one-field response; - hard-coding
turn=0for 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).
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:
setGamereads the persisted{toggles, lastResetTurn}blob. IflastResetTurn < currentTurn, the rune is overwritten withDEFAULT_MAP_TOGGLESand the blob is rewritten withlastResetTurn = currentTurnbefore the report load. Otherwise the persisted overrides are restored.advanceToPending(the user's explicit jump onto the new turn) calls the same reset path afterloadTurn(currentTurn, …)succeeds, updatinglastResetTurnto 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. OnlysetGameandadvanceToPendingwrite to it.viewedTurn— the turn currently rendered.viewTurn(N)flips this rune and the underlyingreporttoNwithout touchingcurrentTurn.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 shell passes it to
Sidebar/BottomTabsso the order tab vanishes; - the shell passes a
getHistoryModegetter toOrderDraftStore.bindClientsoadd/remove/moveare no-ops while the user is looking at a past turn; RenderedReportSourcereturns the raw report (no order overlay) because the draft is composed against the current turn;- the new
HistoryBannercomponent 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.