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>
This commit is contained in:
@@ -27,6 +27,10 @@ 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.
|
||||
@@ -55,9 +59,30 @@ export class GameStateStore {
|
||||
* 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;
|
||||
* later phases (history mode, calc) will read it directly.
|
||||
* 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"
|
||||
@@ -140,16 +165,18 @@ export class GameStateStore {
|
||||
// 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.
|
||||
// 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);
|
||||
await this.loadTurn(lastViewed, { isCurrent: false });
|
||||
} else {
|
||||
await this.loadTurn(summary.currentTurn);
|
||||
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
@@ -196,7 +223,7 @@ export class GameStateStore {
|
||||
}
|
||||
this.gameName = summary.gameName;
|
||||
this.currentTurn = summary.currentTurn;
|
||||
await this.loadTurn(summary.currentTurn);
|
||||
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||
this.pendingTurn = null;
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
@@ -206,29 +233,57 @@ export class GameStateStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* setTurn loads a different turn snapshot — used by Phase 26 history
|
||||
* mode. The current turn stays at whatever `setGame` discovered;
|
||||
* calling without an argument refetches the same turn.
|
||||
* 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 setTurn(turn: number): Promise<void> {
|
||||
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);
|
||||
await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.status = "error";
|
||||
this.error = describe(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh re-fetches the report at the current turn. Called on
|
||||
* window `visibilitychange` so the map and the turn counter stay
|
||||
* fresh after the user returns to the tab.
|
||||
* 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.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.setTurn(this.currentTurn);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,6 +331,7 @@ export class GameStateStore {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -295,20 +351,57 @@ export class GameStateStore {
|
||||
return games.find((g) => g.gameId === gameId) ?? null;
|
||||
}
|
||||
|
||||
private async loadTurn(turn: number): Promise<void> {
|
||||
private async loadTurn(
|
||||
turn: number,
|
||||
opts: { isCurrent: boolean },
|
||||
): Promise<void> {
|
||||
if (this.client === null) return;
|
||||
const report = await fetchGameReport(this.client, this.gameId, turn);
|
||||
const report = await this.readReport(turn, opts.isCurrent);
|
||||
if (this.destroyed) return;
|
||||
this.report = report;
|
||||
this.currentTurn = turn;
|
||||
this.viewedTurn = turn;
|
||||
this.status = "ready";
|
||||
if (this.cache !== null) {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user