= {
"game.shell.menu.language": "язык",
"game.shell.menu.logout": "выйти",
"game.shell.coming_soon": "скоро будет",
+ "game.shell.turn.label": "ход {turn}",
+ "game.shell.turn.list_item": "ход #{turn}",
+ "game.shell.turn.prev": "предыдущий ход",
+ "game.shell.turn.next": "следующий ход",
+ "game.shell.turn.open_navigator": "открыть список ходов",
+ "game.shell.turn.close_navigator": "закрыть список ходов",
+ "game.shell.history.viewing": "Просмотр хода {turn} · только чтение",
+ "game.shell.history.return_to_current": "Вернуться к текущему ходу",
+ "game.shell.history.current_badge": "текущий",
"game.view.map": "карта",
"game.view.table": "таблица",
"game.view.table.planets": "планеты",
diff --git a/ui/frontend/src/lib/rendered-report.svelte.ts b/ui/frontend/src/lib/rendered-report.svelte.ts
index a1c3251..0d7aec6 100644
--- a/ui/frontend/src/lib/rendered-report.svelte.ts
+++ b/ui/frontend/src/lib/rendered-report.svelte.ts
@@ -37,6 +37,12 @@ export interface RenderedReportSource {
* underlying `$state` accesses inside `applyOrderOverlay`, so any
* change to the report or the draft re-runs every dependent
* `$derived` block.
+ *
+ * Phase 26: the order draft is composed against the *current* turn,
+ * so projecting it onto a historical snapshot would render fictional
+ * intent on a past report. In history mode the getter returns the
+ * raw server snapshot untouched — the order tab is hidden anyway and
+ * mutations are gated at the store, so nothing else needs to know.
*/
export function createRenderedReportSource(
gameState: GameStateStore,
@@ -46,6 +52,7 @@ export function createRenderedReportSource(
get report(): GameReport | null {
const raw = gameState.report;
if (raw === null) return null;
+ if (gameState.historyMode) return raw;
return applyOrderOverlay(raw, orderDraft.commands, orderDraft.statuses);
},
};
diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte
index 97ef21f..5d2b9c5 100644
--- a/ui/frontend/src/routes/games/[id]/+layout.svelte
+++ b/ui/frontend/src/routes/games/[id]/+layout.svelte
@@ -47,6 +47,7 @@ fresh.
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Header from "$lib/header/header.svelte";
+ import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte";
@@ -101,9 +102,6 @@ fresh.
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
- // Phase 12 ships the prop wiring; Phase 26 replaces this constant
- // with the real history-mode signal from `lib/history-mode.ts`.
- const historyMode = false;
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
@@ -115,6 +113,13 @@ fresh.
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
+ // Phase 26: the order tab vanishes from the sidebar and bottom-tabs
+ // when the player is viewing a past turn. The flag is owned by
+ // `GameStateStore` (single source of truth for "what turn are we
+ // looking at") and surfaced here so the Phase 12 sidebar wiring,
+ // the new `HistoryBanner`, and `orderDraft.bindClient` all read
+ // from the same derivation.
+ const historyMode = $derived(gameState.historyMode);
const selection = new SelectionStore();
setContext(SELECTION_CONTEXT_KEY, selection);
const renderedReport = createRenderedReportSource(gameState, orderDraft);
@@ -398,6 +403,7 @@ fresh.
galaxyClient.set(client);
orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn,
+ getHistoryMode: () => gameState.historyMode,
});
// The server is always polled at game boot — its
// stored order may be fresher than the local cache
@@ -441,6 +447,7 @@ fresh.
{sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
+
{#if effectiveTool === "calc"}
diff --git a/ui/frontend/src/sync/order-draft.svelte.ts b/ui/frontend/src/sync/order-draft.svelte.ts
index baae0ee..bd093ba 100644
--- a/ui/frontend/src/sync/order-draft.svelte.ts
+++ b/ui/frontend/src/sync/order-draft.svelte.ts
@@ -148,6 +148,7 @@ export class OrderDraftStore {
private queue = new OrderQueue();
private queueStarted = false;
private getCurrentTurn: (() => number) | null = null;
+ private getHistoryMode: (() => boolean) | null = null;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
@@ -195,13 +196,24 @@ export class OrderDraftStore {
* interpolate the turn number the player was composing for. The
* layout passes `() => gameState.currentTurn`; tests may omit it,
* in which case the banner falls back to a turn-less template.
+ *
+ * Phase 26: `opts.getHistoryMode` lets `add` / `remove` / `move`
+ * short-circuit while the user is viewing a past turn. Without
+ * the gate, inspector affordances built in Phases 14–22 would
+ * happily push commands into the draft even though the order tab
+ * is hidden and the read-only banner is visible. Tests may omit
+ * it; the default is "never in history mode".
*/
bindClient(
client: GalaxyClient,
- opts: { getCurrentTurn?: () => number } = {},
+ opts: {
+ getCurrentTurn?: () => number;
+ getHistoryMode?: () => boolean;
+ } = {},
): void {
this.client = client;
this.getCurrentTurn = opts.getCurrentTurn ?? null;
+ this.getHistoryMode = opts.getHistoryMode ?? null;
}
/**
@@ -305,6 +317,11 @@ export class OrderDraftStore {
*/
async add(command: OrderCommand): Promise {
if (this.status !== "ready") return;
+ // Phase 26: history mode hides the order tab and treats every
+ // view as read-only. The inspector affordances are not aware of
+ // the mode, so the gate lives here — one chokepoint protects
+ // every Phase 14–22 caller without per-component edits.
+ if (this.getHistoryMode?.() === true) return;
this.clearConflictForMutation();
const removed: string[] = [];
let nextCommands: OrderCommand[];
@@ -385,6 +402,7 @@ export class OrderDraftStore {
*/
async remove(id: string): Promise {
if (this.status !== "ready") return;
+ if (this.getHistoryMode?.() === true) return;
const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return;
this.clearConflictForMutation();
@@ -406,6 +424,7 @@ export class OrderDraftStore {
*/
async move(fromIndex: number, toIndex: number): Promise {
if (this.status !== "ready") return;
+ if (this.getHistoryMode?.() === true) return;
const length = this.commands.length;
if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return;
@@ -479,6 +498,7 @@ export class OrderDraftStore {
this.cache = null;
this.client = null;
this.getCurrentTurn = null;
+ this.getHistoryMode = null;
if (this.queueStarted) {
this.queue.stop();
this.queueStarted = false;
diff --git a/ui/frontend/tests/e2e/history-mode.spec.ts b/ui/frontend/tests/e2e/history-mode.spec.ts
new file mode 100644
index 0000000..2b1650b
--- /dev/null
+++ b/ui/frontend/tests/e2e/history-mode.spec.ts
@@ -0,0 +1,265 @@
+// Phase 26 end-to-end coverage for history mode. The spec boots an
+// authenticated session, mocks the gateway calls the in-game shell
+// makes (`lobby.my.games.list`, `user.games.report`), pre-seeds a
+// local order draft, and drives the new turn navigator + history
+// banner.
+//
+// The active view is `/table/planets` rather than `/map`: the Pixi
+// renderer can monopolise the headless Chromium main thread for
+// hundreds of ms after a snapshot change, which lets the navigator
+// click win the race against Svelte's reactive flush and the
+// `toContainText` poll find the old "turn ?" state for the entire
+// 5 s polling window. The table view exercises the same `GameReport`
+// data pipeline and the same banner / sidebar wiring without that
+// rendering tail, so the assertions stay deterministic.
+//
+// Gateway mock design notes:
+// - `user.games.order.get` always replies with a non-ok status so
+// `OrderDraftStore.hydrateFromServer` short-circuits into its
+// `syncStatus = "error"` branch without overwriting the local
+// cache. This keeps the pre-seeded draft in memory across the
+// boot path, which is what we need to assert "draft survives a
+// history round-trip".
+// - `user.games.report` answers any requested turn with a turn
+// stamp in the local-planet names so a future diagnostic can
+// prove the rendered snapshot matches the requested turn.
+// - `SubscribeEvents` is held open so the revocation watcher does
+// not bounce the test back to `/login`.
+
+import { fromJson, type JsonValue } from "@bufbuild/protobuf";
+import { expect, test, type Page } from "@playwright/test";
+import { ByteBuffer } from "flatbuffers";
+
+import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
+import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
+import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
+import {
+ buildMyGamesListPayload,
+ type GameFixture,
+} from "./fixtures/lobby-fbs";
+import { buildReportPayload } from "./fixtures/report-fbs";
+
+const SESSION_ID = "phase-26-history-session";
+const GAME_ID = "11111111-2222-3333-4444-555555555555";
+const CURRENT_TURN = 5;
+
+const SEED_DRAFT = [
+ { kind: "placeholder" as const, id: "cmd-a", label: "first" },
+ { kind: "placeholder" as const, id: "cmd-b", label: "second" },
+];
+
+interface MockState {
+ reportRequests: number[];
+}
+
+async function mockGateway(page: Page): Promise {
+ const state: MockState = { reportRequests: [] };
+
+ const baseGame = (): GameFixture => ({
+ gameId: GAME_ID,
+ gameName: "Phase 26 Game",
+ gameType: "private",
+ status: "running",
+ ownerUserId: "user-1",
+ minPlayers: 2,
+ maxPlayers: 8,
+ enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
+ createdAtMs: BigInt(Date.now() - 86_400_000),
+ updatedAtMs: BigInt(Date.now()),
+ currentTurn: CURRENT_TURN,
+ });
+
+ await page.route(
+ "**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
+ async (route) => {
+ const reqText = route.request().postData();
+ if (reqText === null) {
+ await route.fulfill({ status: 400 });
+ return;
+ }
+ const req = fromJson(
+ ExecuteCommandRequestSchema,
+ JSON.parse(reqText) as JsonValue,
+ );
+
+ let resultCode = "ok";
+ let payload: Uint8Array = new Uint8Array(new ArrayBuffer(0));
+
+ const errorPayload = (message: string): Uint8Array => {
+ const text = new TextEncoder().encode(
+ JSON.stringify({ code: "internal_error", message }),
+ );
+ const buf = new ArrayBuffer(text.byteLength);
+ new Uint8Array(buf).set(text);
+ return new Uint8Array(buf);
+ };
+
+ switch (req.messageType) {
+ case "lobby.my.games.list":
+ payload = buildMyGamesListPayload([baseGame()]);
+ break;
+ case "user.games.report": {
+ const decoded = GameReportRequest.getRootAsGameReportRequest(
+ new ByteBuffer(req.payloadBytes),
+ );
+ const turn = decoded.turn();
+ state.reportRequests.push(turn);
+ const localPlanets = [
+ {
+ number: 1,
+ name: `Home-${turn}`,
+ x: 1000,
+ y: 1000,
+ },
+ ];
+ payload = buildReportPayload({
+ turn,
+ mapWidth: 4000,
+ mapHeight: 4000,
+ localPlanets,
+ });
+ break;
+ }
+ case "user.games.order.get": {
+ // Force `hydrateFromServer` into its catch branch so
+ // the seeded local draft survives the boot path.
+ resultCode = "internal_error";
+ payload = errorPayload("test stub");
+ break;
+ }
+ default:
+ resultCode = "internal_error";
+ payload = errorPayload(`unstubbed ${req.messageType}`);
+ }
+
+ const body = await forgeExecuteCommandResponseJson({
+ requestId: req.requestId,
+ timestampMs: BigInt(Date.now()),
+ resultCode,
+ payloadBytes: payload,
+ });
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body,
+ });
+ },
+ );
+
+ await page.route(
+ "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
+ async () => {
+ await new Promise(() => {});
+ },
+ );
+
+ return state;
+}
+
+async function seedShell(page: Page): Promise {
+ await page.goto("/__debug/store");
+ await expect(page.getByTestId("debug-store-ready")).toBeVisible();
+ await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
+ await page.evaluate(() => window.__galaxyDebug!.clearSession());
+ await page.evaluate(
+ (id) => window.__galaxyDebug!.setDeviceSessionId(id),
+ SESSION_ID,
+ );
+ await page.evaluate(
+ ({ gameId, commands }) =>
+ window.__galaxyDebug!.clearOrderDraft(gameId).then(() =>
+ window.__galaxyDebug!.seedOrderDraft(gameId, commands),
+ ),
+ { gameId: GAME_ID, commands: SEED_DRAFT },
+ );
+}
+
+test("navigating to a past turn enters history mode and back-to-current restores the draft", async ({
+ page,
+ isMobile,
+}) => {
+ const state = await mockGateway(page);
+ await seedShell(page);
+
+ await page.goto(`/games/${GAME_ID}/table/planets`);
+ await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
+ `turn ${CURRENT_TURN}`,
+ );
+
+ // Live mode: banner hidden, order tab reachable.
+ await expect(page.getByTestId("history-banner")).toHaveCount(0);
+
+ // Order tab is visible. We expect both Sidebar (desktop / tablet)
+ // and BottomTabs (mobile) wirings — the Phase 12 prop pair flips
+ // off together when historyMode goes true.
+ if (isMobile) {
+ await expect(page.getByTestId("bottom-tab-order")).toBeVisible();
+ } else {
+ await expect(page.getByTestId("sidebar-tab-order")).toBeVisible();
+ }
+
+ // Step back one turn with the prev arrow.
+ await page.getByTestId("turn-navigator-prev").click();
+ await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
+ `turn ${CURRENT_TURN - 1}`,
+ );
+ await expect(page.getByTestId("history-banner")).toBeVisible();
+ await expect(page.getByTestId("history-banner")).toContainText(
+ `Viewing turn ${CURRENT_TURN - 1}`,
+ );
+
+ // Order tab vanishes from both wirings in history mode.
+ if (isMobile) {
+ await expect(page.getByTestId("bottom-tab-order")).toHaveCount(0);
+ } else {
+ await expect(page.getByTestId("sidebar-tab-order")).toHaveCount(0);
+ }
+
+ // Open the navigator popover and jump to turn 2 directly.
+ await page.getByTestId("turn-navigator-trigger").click();
+ const list = page.getByTestId("turn-navigator-list");
+ await expect(list).toBeVisible();
+ await expect(
+ list.getByTestId("turn-navigator-item-0"),
+ ).toBeVisible();
+ await expect(
+ list.getByTestId("turn-navigator-item-5"),
+ ).toBeVisible();
+ await expect(
+ list.getByTestId("turn-navigator-current-badge"),
+ ).toBeVisible();
+
+ await page.getByTestId("turn-navigator-item-2").click();
+ await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
+ "turn 2",
+ );
+ await expect(page.getByTestId("history-banner")).toContainText(
+ "Viewing turn 2",
+ );
+
+ // Click the banner action; live mode resumes.
+ await page.getByTestId("history-banner-return").click();
+ await expect(page.getByTestId("history-banner")).toHaveCount(0);
+ await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
+ `turn ${CURRENT_TURN}`,
+ );
+
+ // Order tab is back and the seeded draft survives the round-trip.
+ if (isMobile) {
+ await page.getByTestId("bottom-tab-order").click();
+ } else {
+ await page.getByTestId("sidebar-tab-order").click();
+ }
+ await expect(page.getByTestId("sidebar-tool-order")).toBeVisible();
+ const list2 = page.getByTestId("order-list");
+ await expect(list2).toBeVisible();
+ for (let i = 0; i < SEED_DRAFT.length; i++) {
+ await expect(page.getByTestId(`order-command-${i}`)).toBeVisible();
+ }
+
+ // The mock served every requested turn (5 on boot, 4 via arrow,
+ // 2 via dropdown, 5 again on return). The exact sequence proves
+ // `viewTurn` does not bypass the network for live turns and
+ // historical fetches hit the gateway when no cache row is present.
+ expect(state.reportRequests).toEqual([5, 4, 2, 5]);
+});
diff --git a/ui/frontend/tests/game-shell-header.test.ts b/ui/frontend/tests/game-shell-header.test.ts
index f218dab..6dde9a9 100644
--- a/ui/frontend/tests/game-shell-header.test.ts
+++ b/ui/frontend/tests/game-shell-header.test.ts
@@ -1,9 +1,11 @@
// Component tests for the in-game shell header. The header composes
-// the headline strip (` @ , turn N`, falling back to `?`
-// while the lobby / report calls are in flight), the view-menu, and
-// the account-menu. The tests assert the headline copy, that every
-// view-menu entry dispatches `goto` with the right URL, and that the
-// Logout entry of the account-menu calls `session.signOut("user")`.
+// the identity strip (` @ `, falling back to `?` while
+// the lobby / report calls are in flight), the Phase 26 turn
+// navigator (`← turn N →` with a popover of every turn), the
+// view-menu, and the account-menu. The tests assert the visible
+// copy, that every view-menu entry dispatches `goto` with the right
+// URL, and that the Logout entry of the account-menu calls
+// `session.signOut("user")`.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -48,6 +50,8 @@ function withGameState(opts: {
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
+ store.currentTurn = opts.turn ?? 0;
+ store.viewedTurn = opts.turn ?? 0;
store.status = "ready";
}
return new Map([[GAME_STATE_CONTEXT_KEY, store]]);
@@ -75,8 +79,11 @@ describe("game-shell header", () => {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
context: withGameState(),
});
- expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
- "? @ ?, turn ?",
+ expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
+ "? @ ?",
+ );
+ expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
+ "turn ?",
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
@@ -91,8 +98,11 @@ describe("game-shell header", () => {
turn: 7,
}),
});
- expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
- "Federation @ Phase 14, turn 7",
+ expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
+ "Federation @ Phase 14",
+ );
+ expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
+ "turn 7",
);
});
@@ -101,8 +111,11 @@ describe("game-shell header", () => {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({ race: "Federation", turn: 3 }),
});
- expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
- "Federation @ ?, turn 3",
+ expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
+ "Federation @ ?",
+ );
+ expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
+ "turn 3",
);
});
diff --git a/ui/frontend/tests/game-state.test.ts b/ui/frontend/tests/game-state.test.ts
index 735f891..b62865f 100644
--- a/ui/frontend/tests/game-state.test.ts
+++ b/ui/frontend/tests/game-state.test.ts
@@ -1,8 +1,10 @@
// Vitest coverage for the per-game runes store
// (`lib/game-state.svelte.ts`). The test stubs `lobby.my.games.list`
// and `user.games.report` at module level and drives the store
-// through its lifecycle: init → ready → error → setTurn → wrap-mode
-// persistence.
+// through its lifecycle: init → ready → error → viewTurn → wrap-mode
+// persistence. Phase 26 adds coverage for history-mode (current vs.
+// viewed turn split, cache-backed re-entry, visibility-refresh
+// short-circuit, resume-from-stale-bookmark flips historyMode on).
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
@@ -250,12 +252,12 @@ describe("GameStateStore", () => {
store.dispose();
});
- test("setTurn loads a different turn snapshot", async () => {
+ test("viewTurn loads a historical snapshot without touching currentTurn", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
- const turns: number[] = [];
- const client = makeFakeClient(async () => {
- const turn = turns.length === 0 ? 3 : 1;
- turns.push(turn);
+ const requestedTurns: number[] = [];
+ const client = makeFakeClient(async (_messageType, payload) => {
+ const turn = decodeRequestedTurn(payload);
+ requestedTurns.push(turn);
return {
resultCode: "ok",
payloadBytes: buildReportPayload({ turn }),
@@ -265,10 +267,104 @@ describe("GameStateStore", () => {
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.report?.turn).toBe(3);
+ expect(store.currentTurn).toBe(3);
+ expect(store.viewedTurn).toBe(3);
+ expect(store.historyMode).toBe(false);
- await store.setTurn(1);
+ await store.viewTurn(1);
expect(store.status).toBe("ready");
expect(store.report?.turn).toBe(1);
+ expect(store.viewedTurn).toBe(1);
+ expect(store.currentTurn).toBe(3);
+ expect(store.historyMode).toBe(true);
+
+ // Phase 26: historical snapshots do not move the
+ // last-viewed-turn cache forward — that resumes-on-open
+ // bookmark must keep meaning "last current turn caught up on",
+ // not "last clicked".
+ const lastViewed = await cache.get(
+ "game-prefs",
+ `${GAME_ID}/last-viewed-turn`,
+ );
+ expect(lastViewed).toBe(3);
+
+ store.dispose();
+ });
+
+ test("returnToCurrent restores the live snapshot and clears historyMode", async () => {
+ listMyGamesSpy.mockResolvedValue([makeGameSummary(4)]);
+ const client = makeFakeClient(async (_messageType, payload) => {
+ const turn = decodeRequestedTurn(payload);
+ return {
+ resultCode: "ok",
+ payloadBytes: buildReportPayload({ turn }),
+ };
+ });
+
+ const store = new GameStateStore();
+ await store.init({ client, cache, gameId: GAME_ID });
+ await store.viewTurn(2);
+ expect(store.historyMode).toBe(true);
+
+ await store.returnToCurrent();
+ expect(store.viewedTurn).toBe(4);
+ expect(store.currentTurn).toBe(4);
+ expect(store.historyMode).toBe(false);
+ expect(store.report?.turn).toBe(4);
+
+ store.dispose();
+ });
+
+ test("viewTurn rejects out-of-range turns without touching state", async () => {
+ listMyGamesSpy.mockResolvedValue([makeGameSummary(2)]);
+ const client = makeFakeClient(async (_messageType, payload) => {
+ const turn = decodeRequestedTurn(payload);
+ return {
+ resultCode: "ok",
+ payloadBytes: buildReportPayload({ turn }),
+ };
+ });
+
+ const store = new GameStateStore();
+ await store.init({ client, cache, gameId: GAME_ID });
+ expect(store.viewedTurn).toBe(2);
+
+ await store.viewTurn(-1);
+ expect(store.viewedTurn).toBe(2);
+ await store.viewTurn(99);
+ expect(store.viewedTurn).toBe(2);
+ await store.viewTurn(Number.NaN);
+ expect(store.viewedTurn).toBe(2);
+
+ store.dispose();
+ });
+
+ test("viewTurn serves repeated historical reads from the game-history cache", async () => {
+ listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
+ let calls = 0;
+ const client = makeFakeClient(async (_messageType, payload) => {
+ calls += 1;
+ const turn = decodeRequestedTurn(payload);
+ return {
+ resultCode: "ok",
+ payloadBytes: buildReportPayload({ turn }),
+ };
+ });
+
+ const store = new GameStateStore();
+ await store.init({ client, cache, gameId: GAME_ID });
+ expect(calls).toBe(1); // boot fetch at turn 5
+
+ await store.viewTurn(1);
+ expect(calls).toBe(2);
+ await store.viewTurn(5);
+ // Returning to the live turn always hits the network — the
+ // current snapshot is mutable until the next tick.
+ expect(calls).toBe(3);
+ await store.viewTurn(1);
+ // Second visit to turn 1 reads from `game-history` cache —
+ // past turns are immutable.
+ expect(calls).toBe(3);
store.dispose();
});
@@ -318,7 +414,15 @@ describe("GameStateStore", () => {
expect(requestedTurns).toEqual([4]);
expect(store.report?.turn).toBe(4);
- expect(store.currentTurn).toBe(4);
+ // Phase 26 splits the runes: `currentTurn` mirrors the lobby's
+ // authoritative `current_turn` (7), `viewedTurn` is the
+ // snapshot actually loaded (4, the last-viewed bookmark from
+ // the previous session). The gap also flips `historyMode` on
+ // so the read-only banner appears alongside the pending-turn
+ // toast.
+ expect(store.currentTurn).toBe(7);
+ expect(store.viewedTurn).toBe(4);
+ expect(store.historyMode).toBe(true);
expect(store.pendingTurn).toBe(7);
store.dispose();
});
@@ -374,17 +478,76 @@ describe("GameStateStore", () => {
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
- expect(store.currentTurn).toBe(2);
+ // `currentTurn` is the server's view (5); the user is held on
+ // the bookmarked turn 2 with the pending-turn affordance.
+ expect(store.currentTurn).toBe(5);
+ expect(store.viewedTurn).toBe(2);
expect(store.pendingTurn).toBe(5);
await store.advanceToPending();
expect(store.currentTurn).toBe(5);
+ expect(store.viewedTurn).toBe(5);
+ expect(store.historyMode).toBe(false);
expect(store.pendingTurn).toBeNull();
expect(requestedTurns).toEqual([2, 5]);
store.dispose();
});
+ test("refresh in history mode does not touch report or viewedTurn", async () => {
+ listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
+ let calls = 0;
+ const client = makeFakeClient(async (_messageType, payload) => {
+ calls += 1;
+ const turn = decodeRequestedTurn(payload);
+ return {
+ resultCode: "ok",
+ payloadBytes: buildReportPayload({ turn }),
+ };
+ });
+
+ const store = new GameStateStore();
+ await store.init({ client, cache, gameId: GAME_ID });
+ await store.viewTurn(2);
+ expect(store.historyMode).toBe(true);
+ const callsBefore = calls;
+
+ await store.refresh();
+ // History mode keeps the displayed report frozen — push events
+ // (Phase 24) carry new-turn notifications asynchronously; the
+ // visibility-driven refresh would otherwise silently kick the
+ // user out of history.
+ expect(calls).toBe(callsBefore);
+ expect(store.viewedTurn).toBe(2);
+ expect(store.currentTurn).toBe(5);
+ expect(store.historyMode).toBe(true);
+
+ store.dispose();
+ });
+
+ test("refresh in live mode refetches the current turn", async () => {
+ listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
+ let calls = 0;
+ const client = makeFakeClient(async (_messageType, payload) => {
+ calls += 1;
+ const turn = decodeRequestedTurn(payload);
+ return {
+ resultCode: "ok",
+ payloadBytes: buildReportPayload({ turn }),
+ };
+ });
+
+ const store = new GameStateStore();
+ await store.init({ client, cache, gameId: GAME_ID });
+ const callsBefore = calls;
+ await store.refresh();
+ expect(calls).toBe(callsBefore + 1);
+ expect(store.viewedTurn).toBe(3);
+ expect(store.currentTurn).toBe(3);
+
+ store.dispose();
+ });
+
test("decodeReport surfaces the localShipClass projection with full attributes", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(1)]);
const client = makeFakeClient(async () => ({
diff --git a/ui/frontend/tests/history-banner.test.ts b/ui/frontend/tests/history-banner.test.ts
new file mode 100644
index 0000000..15385e4
--- /dev/null
+++ b/ui/frontend/tests/history-banner.test.ts
@@ -0,0 +1,63 @@
+// Phase 26 history-banner component tests. The banner is mounted by
+// the in-game shell layout directly under the header; it renders
+// only when `gameState.historyMode === true` and carries a return
+// action delegating to `gameState.returnToCurrent()`.
+
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render } from "@testing-library/svelte";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { i18n } from "../src/lib/i18n/index.svelte";
+import HistoryBanner from "../src/lib/header/history-banner.svelte";
+import {
+ GAME_STATE_CONTEXT_KEY,
+ GameStateStore,
+} from "../src/lib/game-state.svelte";
+
+function buildStore(opts: {
+ currentTurn: number;
+ viewedTurn: number;
+}): GameStateStore {
+ const store = new GameStateStore();
+ store.currentTurn = opts.currentTurn;
+ store.viewedTurn = opts.viewedTurn;
+ store.status = "ready";
+ return store;
+}
+
+beforeEach(() => {
+ i18n.resetForTests("en");
+});
+
+describe("HistoryBanner", () => {
+ test("is hidden in live mode", () => {
+ const store = buildStore({ currentTurn: 5, viewedTurn: 5 });
+ const ui = render(HistoryBanner, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ expect(ui.queryByTestId("history-banner")).toBeNull();
+ });
+
+ test("is visible in history mode with the viewed turn interpolated", () => {
+ const store = buildStore({ currentTurn: 5, viewedTurn: 2 });
+ const ui = render(HistoryBanner, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ const banner = ui.getByTestId("history-banner");
+ expect(banner).toBeInTheDocument();
+ expect(banner).toHaveTextContent("Viewing turn 2");
+ expect(banner).toHaveTextContent("read-only");
+ });
+
+ test("return action delegates to gameState.returnToCurrent", async () => {
+ const store = buildStore({ currentTurn: 5, viewedTurn: 2 });
+ const returnToCurrent = vi
+ .spyOn(store, "returnToCurrent")
+ .mockResolvedValue(undefined);
+ const ui = render(HistoryBanner, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ await fireEvent.click(ui.getByTestId("history-banner-return"));
+ expect(returnToCurrent).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ui/frontend/tests/order-draft.test.ts b/ui/frontend/tests/order-draft.test.ts
index ee946e5..06c9c01 100644
--- a/ui/frontend/tests/order-draft.test.ts
+++ b/ui/frontend/tests/order-draft.test.ts
@@ -809,3 +809,102 @@ describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
store.dispose();
});
});
+
+describe("OrderDraftStore Phase 26 history-mode gate", () => {
+ test("add is a no-op while getHistoryMode returns true", async () => {
+ const store = new OrderDraftStore();
+ await store.init({ cache, gameId: GAME_ID });
+ await store.add(placeholder("c1", "first"));
+ expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
+
+ let history = false;
+ // The store would short-circuit even without bindClient (the
+ // gate runs before any sync logic). Binding a fake client
+ // here mirrors the real layout where `bindClient` is the path
+ // that wires `getHistoryMode` in.
+ store.bindClient(
+ { executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
+ { getHistoryMode: () => history },
+ );
+
+ history = true;
+ await store.add(placeholder("c2", "second"));
+ expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
+
+ history = false;
+ await store.add(placeholder("c3", "third"));
+ expect(store.commands.map((c) => c.id)).toEqual(["c1", "c3"]);
+
+ store.dispose();
+ });
+
+ test("remove is a no-op while getHistoryMode returns true", async () => {
+ const store = new OrderDraftStore();
+ await store.init({ cache, gameId: GAME_ID });
+ await store.add(placeholder("c1", "first"));
+ await store.add(placeholder("c2", "second"));
+
+ let history = true;
+ store.bindClient(
+ { executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
+ { getHistoryMode: () => history },
+ );
+
+ await store.remove("c1");
+ expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
+
+ history = false;
+ await store.remove("c1");
+ expect(store.commands.map((c) => c.id)).toEqual(["c2"]);
+
+ store.dispose();
+ });
+
+ test("move is a no-op while getHistoryMode returns true", async () => {
+ const store = new OrderDraftStore();
+ await store.init({ cache, gameId: GAME_ID });
+ await store.add(placeholder("c1", "first"));
+ await store.add(placeholder("c2", "second"));
+ await store.add(placeholder("c3", "third"));
+
+ let history = true;
+ store.bindClient(
+ { executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
+ { getHistoryMode: () => history },
+ );
+
+ await store.move(0, 2);
+ expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c3"]);
+
+ history = false;
+ await store.move(0, 2);
+ expect(store.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]);
+
+ store.dispose();
+ });
+
+ test("draft survives entering and leaving history mode untouched", async () => {
+ const store = new OrderDraftStore();
+ await store.init({ cache, gameId: GAME_ID });
+ await store.add(placeholder("c1", "first"));
+ await store.add(placeholder("c2", "second"));
+
+ let history = false;
+ store.bindClient(
+ { executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
+ { getHistoryMode: () => history },
+ );
+
+ history = true;
+ // Inspector affordances try to push commands, gate refuses.
+ await store.add(placeholder("c3", "history attempt"));
+ expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
+
+ history = false;
+ expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
+ await store.add(placeholder("c4", "back live"));
+ expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c4"]);
+
+ store.dispose();
+ });
+});
diff --git a/ui/frontend/tests/turn-navigator.test.ts b/ui/frontend/tests/turn-navigator.test.ts
new file mode 100644
index 0000000..7a11908
--- /dev/null
+++ b/ui/frontend/tests/turn-navigator.test.ts
@@ -0,0 +1,168 @@
+// Phase 26 turn-navigator component tests. The navigator owns three
+// affordances: arrows that step ±1 through history, a clickable
+// `turn N` button that opens the full popover, and the popover rows
+// themselves. The store under test is a real `GameStateStore`
+// instance seeded into Svelte context — the navigator never calls
+// the network in tests because we override `viewTurn` /
+// `returnToCurrent` with `vi.fn` spies.
+
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render } from "@testing-library/svelte";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { i18n } from "../src/lib/i18n/index.svelte";
+import TurnNavigator from "../src/lib/header/turn-navigator.svelte";
+import {
+ GAME_STATE_CONTEXT_KEY,
+ GameStateStore,
+} from "../src/lib/game-state.svelte";
+
+function buildStore(opts: {
+ currentTurn: number;
+ viewedTurn: number;
+ ready?: boolean;
+}): GameStateStore {
+ const store = new GameStateStore();
+ store.currentTurn = opts.currentTurn;
+ store.viewedTurn = opts.viewedTurn;
+ store.status = opts.ready === false ? "loading" : "ready";
+ return store;
+}
+
+beforeEach(() => {
+ i18n.resetForTests("en");
+});
+
+describe("TurnNavigator", () => {
+ test("renders `turn ?` when the store is not ready yet", () => {
+ const store = buildStore({ currentTurn: 0, viewedTurn: 0, ready: false });
+ const ui = render(TurnNavigator, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
+ "turn ?",
+ );
+ expect(ui.getByTestId("turn-navigator-trigger")).toBeDisabled();
+ });
+
+ test("prev arrow disabled at viewedTurn = 0", () => {
+ const ui = render(TurnNavigator, {
+ context: new Map([
+ [
+ GAME_STATE_CONTEXT_KEY,
+ buildStore({ currentTurn: 4, viewedTurn: 0 }),
+ ],
+ ]),
+ });
+ expect(ui.getByTestId("turn-navigator-prev")).toBeDisabled();
+ expect(ui.getByTestId("turn-navigator-next")).not.toBeDisabled();
+ });
+
+ test("next arrow disabled at viewedTurn = currentTurn", () => {
+ const ui = render(TurnNavigator, {
+ context: new Map([
+ [
+ GAME_STATE_CONTEXT_KEY,
+ buildStore({ currentTurn: 4, viewedTurn: 4 }),
+ ],
+ ]),
+ });
+ expect(ui.getByTestId("turn-navigator-prev")).not.toBeDisabled();
+ expect(ui.getByTestId("turn-navigator-next")).toBeDisabled();
+ });
+
+ test("prev arrow steps to viewedTurn - 1 via viewTurn", async () => {
+ const store = buildStore({ currentTurn: 4, viewedTurn: 4 });
+ const viewTurn = vi
+ .spyOn(store, "viewTurn")
+ .mockResolvedValue(undefined);
+ const returnToCurrent = vi
+ .spyOn(store, "returnToCurrent")
+ .mockResolvedValue(undefined);
+ const ui = render(TurnNavigator, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ await fireEvent.click(ui.getByTestId("turn-navigator-prev"));
+ expect(viewTurn).toHaveBeenCalledWith(3);
+ expect(returnToCurrent).not.toHaveBeenCalled();
+ });
+
+ test("next arrow at one-step-from-current routes through returnToCurrent", async () => {
+ const store = buildStore({ currentTurn: 4, viewedTurn: 3 });
+ const viewTurn = vi
+ .spyOn(store, "viewTurn")
+ .mockResolvedValue(undefined);
+ const returnToCurrent = vi
+ .spyOn(store, "returnToCurrent")
+ .mockResolvedValue(undefined);
+ const ui = render(TurnNavigator, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ await fireEvent.click(ui.getByTestId("turn-navigator-next"));
+ expect(returnToCurrent).toHaveBeenCalledTimes(1);
+ expect(viewTurn).not.toHaveBeenCalled();
+ });
+
+ test("trigger opens the popover with every turn in descending order", async () => {
+ const store = buildStore({ currentTurn: 3, viewedTurn: 1 });
+ const ui = render(TurnNavigator, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ await fireEvent.click(ui.getByTestId("turn-navigator-trigger"));
+ const list = ui.getByTestId("turn-navigator-list");
+ expect(list).toBeInTheDocument();
+
+ const rows = list.querySelectorAll("button[role='menuitem']");
+ expect(rows.length).toBe(4);
+ expect(rows[0]).toHaveAttribute(
+ "data-testid",
+ "turn-navigator-item-3",
+ );
+ expect(rows[3]).toHaveAttribute(
+ "data-testid",
+ "turn-navigator-item-0",
+ );
+ // Current-turn row carries the badge.
+ const currentRow = ui.getByTestId("turn-navigator-item-3");
+ expect(currentRow.querySelector("[data-testid='turn-navigator-current-badge']"))
+ .not.toBeNull();
+ // Other rows do not carry a badge.
+ const otherRow = ui.getByTestId("turn-navigator-item-2");
+ expect(otherRow.querySelector("[data-testid='turn-navigator-current-badge']"))
+ .toBeNull();
+ });
+
+ test("selecting a past row delegates to viewTurn(N)", async () => {
+ const store = buildStore({ currentTurn: 3, viewedTurn: 3 });
+ const viewTurn = vi
+ .spyOn(store, "viewTurn")
+ .mockResolvedValue(undefined);
+ const returnToCurrent = vi
+ .spyOn(store, "returnToCurrent")
+ .mockResolvedValue(undefined);
+ const ui = render(TurnNavigator, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ await fireEvent.click(ui.getByTestId("turn-navigator-trigger"));
+ await fireEvent.click(ui.getByTestId("turn-navigator-item-1"));
+ expect(viewTurn).toHaveBeenCalledWith(1);
+ expect(returnToCurrent).not.toHaveBeenCalled();
+ });
+
+ test("selecting the current row delegates to returnToCurrent", async () => {
+ const store = buildStore({ currentTurn: 3, viewedTurn: 1 });
+ const viewTurn = vi
+ .spyOn(store, "viewTurn")
+ .mockResolvedValue(undefined);
+ const returnToCurrent = vi
+ .spyOn(store, "returnToCurrent")
+ .mockResolvedValue(undefined);
+ const ui = render(TurnNavigator, {
+ context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
+ });
+ await fireEvent.click(ui.getByTestId("turn-navigator-trigger"));
+ await fireEvent.click(ui.getByTestId("turn-navigator-item-3"));
+ expect(returnToCurrent).toHaveBeenCalledTimes(1);
+ expect(viewTurn).not.toHaveBeenCalled();
+ });
+});