Files
galaxy-game/ui/frontend/src/lib/game-state.svelte.ts
T
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

448 lines
15 KiB
TypeScript

// Per-game runtime state owned by the in-game shell layout
// (`routes/games/[id]/+layout.svelte`). The store discovers the
// game's current turn through `lobby.my.games.list`, fetches the
// matching `user.games.report`, and exposes a TS-friendly `GameReport`
// snapshot to every consumer (header turn counter, map view,
// inspector tabs in later phases).
//
// Phase 11 covers planets only; later phases extend the report
// surface as their slice of state lands. Every consumer reads from
// the same store instance — instantiation per game guarantees the
// layout remount on `gameId` change reseeds the snapshot, while
// navigation between active views inside the same game keeps the
// instance alive (state-preservation rule, see ui/docs/navigation.md).
import {
GameStateError,
fetchGameReport,
type GameReport,
} from "../api/game-state";
import { listMyGames, type GameSummary } from "../api/lobby";
import type { GalaxyClient } from "../api/galaxy-client";
import type { Cache } from "../platform/store/index";
import type { WrapMode } from "../map/world";
const PREF_NAMESPACE = "game-prefs";
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
`${gameId}/last-viewed-turn`;
const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
`${gameId}/turn/${turn}`;
/**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `GameStateStore` instance to descendants.
* Header / map / inspector children resolve the store via
* `getContext(GAME_STATE_CONTEXT_KEY)`.
*/
export const GAME_STATE_CONTEXT_KEY = Symbol("game-state");
type Status = "idle" | "loading" | "ready" | "error";
export class GameStateStore {
gameId: string = $state("");
/**
* gameName mirrors the lobby's `game_name` for the running game.
* Lifted from the lobby record on `setGame`; empty during boot
* and set once the lobby query resolves. Used by the header to
* compose the `<race> @ <game>, turn N` display.
*/
gameName: string = $state("");
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
error: string | null = $state(null);
/**
* currentTurn mirrors the engine's turn number for the running
* game (lifted from the lobby record on `setGame`). Phase 14
* exposes it so the layout can pass it to
* `OrderDraftStore.hydrateFromServer` after both stores boot;
* Phase 26 keeps the "authoritative server-side turn" meaning —
* only `setGame`, `advanceToPending`, and the visibility-listener
* lobby re-query update it. History navigation (`viewTurn`) leaves
* it alone so the "Return to current turn" affordance keeps a
* reliable target.
*/
currentTurn = $state(0);
/**
* viewedTurn is the turn whose snapshot is currently displayed.
* In live mode it equals `currentTurn`. Phase 26 history mode
* decouples the two: `viewTurn(N)` flips this rune (and `report`)
* to N without touching `currentTurn` or `last-viewed-turn`.
*/
viewedTurn = $state(0);
/**
* historyMode is the derived "user is viewing a past turn" rune
* consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
* history banner, the rendered-report overlay short-circuit, and
* the order-draft mutation gate. It depends only on the rune state
* above, so every consumer reacts to a single source of truth.
*/
historyMode = $derived(
this.status === "ready" && this.viewedTurn < this.currentTurn,
);
/**
* synthetic is set by `initSynthetic` for DEV-only sessions backed
* by a hand-loaded report (lobby's "Load synthetic report"
* affordance). The flag travels through the layout so the order
* tab and any future server-bound features can short-circuit and
* stay local. The auto-sync pipeline already protects itself via
* the UUID guard on `OrderDraftStore.scheduleSync`, so flipping
* this flag is enough to keep the network silent.
*/
synthetic = $state(false);
/**
* pendingTurn carries the latest server-side turn the user has not
* yet opened: it is `> currentTurn` whenever the server reports a
* new turn (either through a `game.turn.ready` push event after
* boot, or through the boot-time discovery that the persisted
* `lastViewedTurn` is behind the lobby's `current_turn`). The
* layout's `$effect` renders a toast/banner when it is non-null;
* `advanceToPending()` refreshes the store onto the new turn and
* clears the rune.
*/
pendingTurn: number | null = $state(null);
private client: GalaxyClient | null = null;
private cache: Cache | null = null;
private destroyed = false;
private visibilityListener: (() => void) | null = null;
/**
* init kicks off the per-game lifecycle. The call is idempotent on
* the same `gameId`; calling with a different game forwards through
* `setGame` so the layout can hand off across navigations.
*/
async init(opts: {
client: GalaxyClient;
cache: Cache;
gameId: string;
}): Promise<void> {
this.client = opts.client;
this.cache = opts.cache;
await this.setGame(opts.gameId);
this.installVisibilityListener();
}
/**
* setGame switches the store to the supplied game id, fetches the
* matching lobby record to discover `current_turn`, then loads the
* report. Failure paths surface through `status === "error"` and
* the matching `error` string (already localised by the caller).
*/
async setGame(gameId: string): Promise<void> {
if (this.client === null || this.cache === null) {
throw new Error("game-state: setGame called before init");
}
// Only forget the pending indicator when the consumer is
// actually switching games. On the initial `setGame` after
// `init` the previous `gameId` is the empty string, and a
// concurrent `markPendingTurn` from a push event arriving
// while we were still bootstrapping must not be erased.
if (this.gameId !== "" && this.gameId !== gameId) {
this.pendingTurn = null;
}
this.gameId = gameId;
this.status = "loading";
this.error = null;
this.report = null;
this.wrapMode = await readWrapMode(this.cache, gameId);
const lastViewed = await readLastViewedTurn(this.cache, gameId);
try {
const summary = await this.findGame(gameId);
if (summary === null) {
this.status = "error";
this.error = `game ${gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
// If the persisted last-viewed turn is older than the
// server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the
// shell can render a "new turn available" affordance instead
// of silently auto-advancing. After Phase 26 the same gap
// also flips `historyMode` to true (viewedTurn < currentTurn),
// so the read-only banner appears alongside the toast.
if (
lastViewed !== null &&
lastViewed >= 0 &&
lastViewed < summary.currentTurn
) {
this.pendingTurn = summary.currentTurn;
await this.loadTurn(lastViewed, { isCurrent: false });
} else {
await this.loadTurn(summary.currentTurn, { isCurrent: true });
}
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* markPendingTurn records a server-reported new turn (typically
* delivered through `game.turn.ready`). Values that are not
* strictly ahead of the latest known turn (current or already
* pending) are ignored so a replayed event cannot regress the
* indicator.
*/
markPendingTurn(turn: number): void {
const latest = this.pendingTurn ?? this.currentTurn;
if (turn > latest) {
this.pendingTurn = turn;
}
}
/**
* advanceToPending re-queries the lobby record and loads the
* report at the server's latest `current_turn`, then clears the
* pending indicator. Unlike `setGame`, this skips the
* `lastViewedTurn` lookup — the user has explicitly asked to
* jump to the new turn, so any persisted bookmark from the
* previous session is irrelevant. Failures keep the indicator
* set so the user can retry from the same affordance.
*/
async advanceToPending(): Promise<void> {
if (this.pendingTurn === null || this.client === null) {
return;
}
this.status = "loading";
this.error = null;
try {
const summary = await this.findGame(this.gameId);
if (summary === null) {
this.status = "error";
this.error = `game ${this.gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn, { isCurrent: true });
this.pendingTurn = null;
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* viewTurn loads the historical snapshot for `turn` and switches the
* UI into history mode (Phase 26). The current turn is untouched —
* `historyMode` flips on automatically through the derived rune, and
* the `last-viewed-turn` cache is only refreshed when the caller
* happens to ask for the currentTurn (e.g. `returnToCurrent`). A
* cache hit on `game-history/{gameId}/turn/{N}` skips the network;
* past turns are immutable so the cache never goes stale.
*/
async viewTurn(turn: number): Promise<void> {
if (this.client === null) return;
if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
return;
}
this.status = "loading";
this.error = null;
try {
await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* returnToCurrent jumps back to the server's current turn after a
* history excursion. Thin wrapper around `viewTurn(currentTurn)` so
* the banner / popover share the same call site.
*/
returnToCurrent(): Promise<void> {
return this.viewTurn(this.currentTurn);
}
/**
* refresh is fired from the `visibilitychange` listener. In live
* mode it re-fetches the report at the current turn so the map and
* the counter catch up after the user returns to the tab. In
* history mode it is a no-op: the user is intentionally viewing a
* past turn, push events (Phase 24) deliver new-turn notifications
* asynchronously, and forcing a reload would silently bump the
* user out of history mode.
*/
async refresh(): Promise<void> {
if (this.client === null) return;
if (this.historyMode) return;
try {
await this.loadTurn(this.currentTurn, { isCurrent: true });
} catch (err) {
if (this.destroyed) return;
console.warn("game-state: refresh failed", err);
}
}
/**
* setWrapMode persists the per-game preference into Cache so the
* next visit to the game restores it. Phase 29 wires the toggle UI;
* Phase 11 only reads through `wrapMode` and writes via this method.
*/
async setWrapMode(mode: WrapMode): Promise<void> {
this.wrapMode = mode;
if (this.cache !== null) {
await this.cache.put(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(this.gameId), mode);
}
}
/**
* failBootstrap is used by the layout to surface errors that
* happen *before* `init` could be reached (missing keypair, missing
* gateway public key, core/store load failure). It does not need
* `init` to have run first.
*/
failBootstrap(message: string): void {
this.status = "error";
this.error = message;
}
/**
* initSynthetic seeds the store from a pre-loaded `GameReport`
* without touching the network. Used by the lobby's DEV-only
* "Load synthetic report" affordance: the layout invokes this
* instead of `init` when the route id is in the synthetic id
* range. The store ends up in `ready` immediately; no polling,
* no visibility-driven refresh, no client / cache-of-server
* binding.
*/
async initSynthetic(opts: {
cache: Cache;
gameId: string;
report: GameReport;
}): Promise<void> {
this.cache = opts.cache;
this.gameId = opts.gameId;
this.synthetic = true;
this.gameName = "Synthetic";
this.error = null;
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
this.report = opts.report;
this.currentTurn = opts.report.turn;
this.viewedTurn = opts.report.turn;
this.status = "ready";
}
dispose(): void {
this.destroyed = true;
if (this.visibilityListener !== null && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", this.visibilityListener);
}
this.visibilityListener = null;
this.client = null;
this.cache = null;
}
private async findGame(gameId: string): Promise<GameSummary | null> {
if (this.client === null) return null;
const games = await listMyGames(this.client);
return games.find((g) => g.gameId === gameId) ?? null;
}
private async loadTurn(
turn: number,
opts: { isCurrent: boolean },
): Promise<void> {
if (this.client === null) return;
const report = await this.readReport(turn, opts.isCurrent);
if (this.destroyed) return;
this.report = report;
this.viewedTurn = turn;
this.status = "ready";
if (this.cache === null) return;
if (opts.isCurrent) {
// Persist last-viewed-turn only when the user is caught up
// on the live snapshot. Historical excursions are ephemeral
// (Phase 26 decision): the resume-on-open affordance from
// Phase 11 must keep meaning "the latest turn this player
// was caught up on", not "wherever they last clicked".
await this.cache.put(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(this.gameId),
turn,
);
return;
}
// Past turns are immutable, so the snapshot is safe to cache
// for fast re-entry. The current-turn snapshot deliberately
// skips the cache — it is mutable until the next tick.
await this.cache.put(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
report,
);
}
private async readReport(
turn: number,
isCurrent: boolean,
): Promise<GameReport> {
if (this.client === null) {
throw new Error("game-state: readReport called without client");
}
if (!isCurrent && this.cache !== null) {
const cached = await this.cache.get<GameReport>(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
);
if (cached !== undefined && cached.turn === turn) {
return cached;
}
}
return await fetchGameReport(this.client, this.gameId, turn);
}
private installVisibilityListener(): void {
if (typeof document === "undefined") return;
const listener = (): void => {
if (document.visibilityState === "visible" && this.status === "ready") {
void this.refresh();
}
};
this.visibilityListener = listener;
document.addEventListener("visibilitychange", listener);
}
}
async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
const stored = await cache.get<string>(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(gameId));
if (stored === "no-wrap") return "no-wrap";
return "torus";
}
async function readLastViewedTurn(
cache: Cache,
gameId: string,
): Promise<number | null> {
const stored = await cache.get<number>(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(gameId),
);
if (typeof stored !== "number" || !Number.isFinite(stored)) {
return null;
}
return stored;
}
function describe(err: unknown): string {
if (err instanceof GameStateError) {
return err.message;
}
if (err instanceof Error) {
return err.message;
}
return "request failed";
}