2d17760a5e
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>
448 lines
15 KiB
TypeScript
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";
|
|
}
|