Split GameStateStore into currentTurn (server's latest) and viewedTurn (displayed snapshot) so history excursions don't corrupt the resume bookmark or the live-turn bound. Add viewTurn / returnToCurrent / historyMode rune, plus a game-history cache namespace that stores past-turn reports for fast re-entry. OrderDraftStore.bindClient takes a getHistoryMode getter and short-circuits add / remove / move while the user is viewing a past turn; RenderedReportSource skips the order overlay in the same case. Header replaces the static "turn N" with a clickable triplet (TurnNavigator), the layout mounts HistoryBanner under the header, and visibility-refresh is a no-op while history is active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.7 KiB
Per-game state store
This document describes the per-game state owned by the in-game shell layout. Phase 11 introduces the store and uses it for two consumers (the header turn counter and the map view); later phases plug inspector tabs, the order composer, and the calculator on top of the same instance.
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:
- 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.listand finds the game record (the Phase 11 wire schema extension onGameSummaryaddscurrent_turn); if the user is not a member, the store flips toerror; - 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" |
Phase boundaries
- Phase 11 surfaces only the planet subset of the report. Later
phases extend
GameReportanddecodeReportas their slice of the wire lands (ships, fleets, sciences, routes, battles, mail). - Phase 26 splits
currentTurnfrom the turn whose snapshot is displayed (viewedTurn) and addsviewTurn(turn)/returnToCurrent()for history navigation. The derivedhistoryModerune flips automatically whenviewedTurn < currentTurn; the layout passes it to Phase 12's sidebar / bottom-tabs wiring (which hides the order tab) and toOrderDraftStore.bindClient(which gatesadd/remove/move). See "History mode" below for the cache and refresh rules. - Phase 24 replaces the tab-focus refresh with push-event-driven refreshes; the visibility listener stays as a fallback for background tabs that miss a push.
- Phase 29 wires the wrap-mode toggle UI 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 wire change touches Phase 8's already-shipped catalogue, but 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. Phase 11 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; later phases will refine the
colour palette as the visual language stabilises (Phase 35 polish).
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 (Phase 24) 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.
History mode
Phase 26 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 layout passes it to
Sidebar/BottomTabsso the order tab vanishes (Phase 12 prop wiring); - the layout 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 semantics keep their Phase 11 meaning: "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 in Phase 26.
The current-turn snapshot is deliberately not cached — it is
mutable until the next engine tick.