0cae89cba2
Tests · Go / test (push) Successful in 1m59s
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.
208 lines
10 KiB
Markdown
208 lines
10 KiB
Markdown
# 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`](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](../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.
|