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

158 lines
7.7 KiB
Markdown

# 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.