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:
Ilia Denisov
2026-05-12 00:13:19 +02:00
parent 070fdc0ee5
commit 2d17760a5e
20 changed files with 1572 additions and 118 deletions
+173 -10
View File
@@ -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<number>(
"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 () => ({