Files
galaxy-game/ui/docs/game-state.md
T
Ilia Denisov 0cae89cba2
Tests · Go / test (push) Successful in 1m59s
refactor(dev): remove the dev-sandbox bootstrap everywhere
Stage 1 of the dev-as-prod-mirror rework. The auto-provisioned "Dev
Sandbox" game and dummy users are removed so the dev contour starts
empty like prod; the separate legacy-report loader stays as the
test-data path.

- delete backend/internal/devsandbox (package + tests)
- drop the bootstrap call + DevSandboxConfig (struct, Config field,
  BACKEND_DEV_SANDBOX_* env, defaults, loader, validation)
- strip BACKEND_DEV_SANDBOX_* from dev-deploy + local-dev compose and
  .env.example; the generic engine-recycle / prune-broken-engines logic
  stays (it serves real games)
- update tooling docs (dev-deploy README + KNOWN-ISSUES, local-dev
  README + Makefile) and stale comments; DeleteGame and
  InsertMembershipDirect remain (exercised by lobby integration tests)

No app behaviour change beyond not auto-creating the sandbox game.
2026-05-31 22:28:03 +02:00

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:

  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 (findGame) and finds the game record (GameSummary carries current_turn); if the game is not in the player's list, the store sets the notFound flag (see below);
    • 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"
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.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 a synthetic report loaded at turn zero but mis-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 synthetic-report 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:

  • 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 shell passes it to Sidebar / BottomTabs so the order tab vanishes;
  • the shell 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.