680ebac919
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the renderer divides by the current camera scale on every `viewport.zoomed` so thin lines / small markers stay the same on-screen size at any zoom. * Known-size planets switch to `pointRadiusWorld`, softened against the reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified planets pin to a 3-px disc. * New planet label layer renders a two-line `name / #N` legend under each planet (`#N` only for unidentified or when the new `planetNames` toggle is off). Selection now paints an inverse-fill frame around the selected planet's label plus an outline on the disc; the old selection-ring primitive is retired. * Bombing markers swap the separate CirclePrim for a planet-outline overlay (damaged / wiped colour); the report deep-link moves to a "view bombing report" link in the planet inspector. * Docs + tests follow: `renderer.md` reflects the new sizing contract + label / outline layers, vitest covers the sizing math, label formatting, and the new toggle, and the map-toggles e2e adds a persistence case for `planetNames`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
700 lines
24 KiB
TypeScript
700 lines
24 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}`;
|
|
|
|
const MAP_TOGGLES_NAMESPACE = "game-map-toggles";
|
|
|
|
/**
|
|
* MapToggles is the per-game visibility state exposed by the Phase 29
|
|
* gear popover. Every flip persists into `Cache` under
|
|
* `MAP_TOGGLES_NAMESPACE/<gameId>` so the next visit to the game keeps
|
|
* the user's choices; a new server-side turn force-resets the blob to
|
|
* `DEFAULT_MAP_TOGGLES` so a hidden category never makes the player
|
|
* miss what changed (see `GameStateStore.setGame` and
|
|
* `advanceToPending`).
|
|
*
|
|
* Categories with no per-toggle entry are always visible: `local`
|
|
* planets, in-orbit / on-planet ship groups (rendered by the planet
|
|
* inspector, never on the map), and the pending-Send overlay.
|
|
*/
|
|
export interface MapToggles {
|
|
hyperspaceGroups: boolean;
|
|
incomingGroups: boolean;
|
|
unidentifiedGroups: boolean;
|
|
foreignPlanets: boolean;
|
|
uninhabitedPlanets: boolean;
|
|
unidentifiedPlanets: boolean;
|
|
unreachablePlanets: boolean;
|
|
cargoRoutes: boolean;
|
|
battleMarkers: boolean;
|
|
bombingMarkers: boolean;
|
|
/**
|
|
* planetNames toggles the on-map two-line label drawn under each
|
|
* planet (F8-12 / issue #55, п.29). When ON, the first line shows
|
|
* the planet name (when known) and the second line shows `#N`.
|
|
* When OFF, the name line is suppressed for every planet — only
|
|
* `#N` remains. Default ON.
|
|
*/
|
|
planetNames: boolean;
|
|
/**
|
|
* visibleHyperspace toggles the foggy overlay that darkens the
|
|
* world OUTSIDE the union of `VisibilityDistance` circles around
|
|
* LOCAL planets. The visible part of the map — the player's
|
|
* intelligence/scan coverage — stays in the regular background
|
|
* colour; everything else looks "foggy". Default ON.
|
|
*/
|
|
visibleHyperspace: boolean;
|
|
}
|
|
|
|
export const DEFAULT_MAP_TOGGLES: MapToggles = {
|
|
hyperspaceGroups: true,
|
|
incomingGroups: true,
|
|
unidentifiedGroups: true,
|
|
foreignPlanets: true,
|
|
uninhabitedPlanets: true,
|
|
unidentifiedPlanets: true,
|
|
unreachablePlanets: true,
|
|
cargoRoutes: true,
|
|
battleMarkers: true,
|
|
bombingMarkers: true,
|
|
planetNames: true,
|
|
visibleHyperspace: true,
|
|
};
|
|
|
|
interface PersistedMapToggles {
|
|
readonly toggles: MapToggles;
|
|
readonly lastResetTurn: number;
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
/**
|
|
* mapToggles is the per-game visibility state surfaced by the
|
|
* Phase 29 gear popover. Every value defaults to `true` except for
|
|
* the negative `unreachablePlanets` flag (which is also `true` so
|
|
* the default view shows every reachable planet). The map view
|
|
* resolves the flags into a hide-by-id set on every effect run via
|
|
* `RendererHandle.setHiddenPrimitiveIds`.
|
|
*/
|
|
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
|
/**
|
|
* lastCamera is the most recent map camera snapshot (centre + zoom)
|
|
* captured before the renderer was disposed — either because the
|
|
* map view unmounted (active-view switch) or because it remounted
|
|
* inside the same session. `map.svelte` reads it on cold mount so
|
|
* leaving the map for a table / report and coming back keeps the
|
|
* user's prior pan / zoom. Resetting to null falls back to the
|
|
* default world-centre + minScale fit. Held in memory only; an F5
|
|
* re-loads the report and the default fit takes over.
|
|
*/
|
|
lastCamera: { centerX: number; centerY: number; scale: number } | null = $state(null);
|
|
error: string | null = $state(null);
|
|
/**
|
|
* notFound is the distinct "this game is not in the player's list"
|
|
* signal, set when `findGame` returns null (cancelled, removed, or
|
|
* access revoked). It is a clean flag the app-shell reads after
|
|
* `init` to drop a restored/stale game back to the lobby with a
|
|
* toast, rather than string-matching the `error` message. Transient
|
|
* network failures keep `notFound` false (they take the catch path)
|
|
* so they still surface the in-game error state for a retry. Reset
|
|
* to false at the start of every `setGame` / `advanceToPending`.
|
|
*/
|
|
notFound = $state(false);
|
|
/**
|
|
* 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;
|
|
/**
|
|
* lastResetTurn is the turn at which `mapToggles` was last reset to
|
|
* defaults. Persisted alongside the toggle blob so the new-turn
|
|
* reset path can compare against `currentTurn` after a cross-
|
|
* session gap (browser closed at turn N, reopened at turn N + k).
|
|
*/
|
|
private lastResetTurn = 0;
|
|
|
|
/**
|
|
* 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.notFound = false;
|
|
this.report = null;
|
|
|
|
this.wrapMode = await readWrapMode(this.cache, gameId);
|
|
const lastViewed = await readLastViewedTurn(this.cache, gameId);
|
|
const persistedToggles = await readMapToggles(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`;
|
|
this.notFound = true;
|
|
return;
|
|
}
|
|
this.gameName = summary.gameName;
|
|
this.currentTurn = summary.currentTurn;
|
|
// New-turn reset: if the persisted blob is older than the
|
|
// server-side `currentTurn`, drop user overrides and write
|
|
// the fresh `{defaults, currentTurn}` back to cache so a
|
|
// subsequent reload sees the same baseline. The cross-
|
|
// session gap counts here too — a player who closed the
|
|
// tab at turn N and returns at turn N + k still gets the
|
|
// defaults on first map mount.
|
|
if (persistedToggles.lastResetTurn < summary.currentTurn) {
|
|
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
|
this.lastResetTurn = summary.currentTurn;
|
|
await writeMapToggles(
|
|
this.cache,
|
|
gameId,
|
|
this.mapToggles,
|
|
this.lastResetTurn,
|
|
);
|
|
} else {
|
|
this.mapToggles = { ...persistedToggles.toggles };
|
|
this.lastResetTurn = persistedToggles.lastResetTurn;
|
|
}
|
|
// 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;
|
|
this.notFound = false;
|
|
try {
|
|
const summary = await this.findGame(this.gameId);
|
|
if (summary === null) {
|
|
this.status = "error";
|
|
this.error = `game ${this.gameId} is not in your list`;
|
|
this.notFound = true;
|
|
return;
|
|
}
|
|
this.gameName = summary.gameName;
|
|
this.currentTurn = summary.currentTurn;
|
|
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
|
this.pendingTurn = null;
|
|
// Phase 29: a successful jump onto the new server turn
|
|
// drops user-set map-visibility overrides so the next
|
|
// frame surfaces every category. `viewTurn` is the
|
|
// history-mode path and intentionally leaves toggles
|
|
// alone — the single shared state stays put across
|
|
// in-game time-travel.
|
|
await this.resetMapTogglesForTurn(summary.currentTurn);
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* setMapToggle flips one entry of the `mapToggles` rune and
|
|
* persists the whole blob (alongside the unchanged
|
|
* `lastResetTurn`). Mutating the rune in place keeps subscribers
|
|
* reactive without requiring object identity changes.
|
|
*/
|
|
async setMapToggle<K extends keyof MapToggles>(
|
|
key: K,
|
|
value: MapToggles[K],
|
|
): Promise<void> {
|
|
this.mapToggles[key] = value;
|
|
if (this.cache !== null) {
|
|
await writeMapToggles(
|
|
this.cache,
|
|
this.gameId,
|
|
this.mapToggles,
|
|
this.lastResetTurn,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async resetMapTogglesForTurn(turn: number): Promise<void> {
|
|
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
|
this.lastResetTurn = turn;
|
|
if (this.cache !== null) {
|
|
await writeMapToggles(
|
|
this.cache,
|
|
this.gameId,
|
|
this.mapToggles,
|
|
this.lastResetTurn,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
// Synthetic sessions skip the lobby query, so the new-turn
|
|
// reset check uses the report's own turn as the reference. A
|
|
// reload on the same synthetic id restores user overrides;
|
|
// switching to a synthetic report with a higher turn resets
|
|
// them.
|
|
const persistedToggles = await readMapToggles(opts.cache, opts.gameId);
|
|
if (persistedToggles.lastResetTurn < opts.report.turn) {
|
|
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
|
this.lastResetTurn = opts.report.turn;
|
|
await writeMapToggles(
|
|
opts.cache,
|
|
opts.gameId,
|
|
this.mapToggles,
|
|
this.lastResetTurn,
|
|
);
|
|
} else {
|
|
this.mapToggles = { ...persistedToggles.toggles };
|
|
this.lastResetTurn = persistedToggles.lastResetTurn;
|
|
}
|
|
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;
|
|
// Idempotent on re-init. In the single-URL app-shell a direct
|
|
// game → game switch (a push deep-link or a refresh-restore)
|
|
// re-runs `init` without unmounting the shell, so a naive
|
|
// `addEventListener` here would stack a second listener on every
|
|
// switch. Drop any previously-registered one first so exactly one
|
|
// stays live.
|
|
if (this.visibilityListener !== null) {
|
|
document.removeEventListener(
|
|
"visibilitychange",
|
|
this.visibilityListener,
|
|
);
|
|
}
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* readMapToggles loads the persisted `{toggles, lastResetTurn}` blob.
|
|
* Missing entries (cleared site data, fresh game) return the defaults
|
|
* with `lastResetTurn === -1`, guaranteeing the `setGame` reset path
|
|
* runs on the very first visit. Per-field fallback to defaults keeps
|
|
* forward-compat with future toggle additions: an older blob
|
|
* persisted before a new flag landed loses nothing but the missing
|
|
* flag, which gets the default value.
|
|
*/
|
|
async function readMapToggles(
|
|
cache: Cache,
|
|
gameId: string,
|
|
): Promise<PersistedMapToggles> {
|
|
const stored = await cache.get<Partial<PersistedMapToggles>>(
|
|
MAP_TOGGLES_NAMESPACE,
|
|
gameId,
|
|
);
|
|
if (stored === undefined || stored === null || typeof stored !== "object") {
|
|
return { toggles: { ...DEFAULT_MAP_TOGGLES }, lastResetTurn: -1 };
|
|
}
|
|
const partial =
|
|
stored.toggles !== undefined &&
|
|
stored.toggles !== null &&
|
|
typeof stored.toggles === "object"
|
|
? stored.toggles
|
|
: {};
|
|
const toggles: MapToggles = { ...DEFAULT_MAP_TOGGLES };
|
|
for (const k of Object.keys(DEFAULT_MAP_TOGGLES) as (keyof MapToggles)[]) {
|
|
const candidate = (partial as Partial<MapToggles>)[k];
|
|
if (typeof candidate === "boolean") {
|
|
toggles[k] = candidate;
|
|
}
|
|
}
|
|
const turn =
|
|
typeof stored.lastResetTurn === "number" &&
|
|
Number.isFinite(stored.lastResetTurn)
|
|
? stored.lastResetTurn
|
|
: -1;
|
|
return { toggles, lastResetTurn: turn };
|
|
}
|
|
|
|
async function writeMapToggles(
|
|
cache: Cache,
|
|
gameId: string,
|
|
toggles: MapToggles,
|
|
lastResetTurn: number,
|
|
): Promise<void> {
|
|
await cache.put<PersistedMapToggles>(MAP_TOGGLES_NAMESPACE, gameId, {
|
|
toggles: { ...toggles },
|
|
lastResetTurn,
|
|
});
|
|
}
|
|
|
|
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";
|
|
}
|