Files
Ilia Denisov 2d17760a5e ui/phase-26: history mode (turn navigator + read-only banner)
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>
2026-05-12 00:13:19 +02:00

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:

  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 / <gameId>/wrap-mode, default torus);
    • calls lobby.my.games.list and finds the game record (the Phase 11 wire schema extension on GameSummary adds 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"

Phase boundaries

  • Phase 11 surfaces only the planet subset of the report. Later phases extend GameReport and decodeReport as their slice of the wire lands (ships, fleets, sciences, routes, battles, mail).
  • Phase 26 splits currentTurn from the turn whose snapshot is displayed (viewedTurn) and adds viewTurn(turn) / returnToCurrent() for history navigation. The derived historyMode rune flips automatically when viewedTurn < currentTurn; the layout passes it to Phase 12's 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.
  • 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.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 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. 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 (Phase 12 prop wiring);
  • 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 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.