diff --git a/ui/PLAN.md b/ui/PLAN.md index 7b21ced..3719429 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1381,9 +1381,13 @@ Decisions taken with the project owner during implementation: 6. **`historyMode` as a prop, not a module.** Layout passes `historyMode={false}` (a constant in Phase 12) to `Sidebar` and `BottomTabs`; both forward to their tab-bar children which omit - the order entry when the flag is true. Phase 26 introduces the - real `lib/history-mode.ts` module and replaces the constant in - one place. + the order entry when the flag is true. Phase 26 superseded the + "introduce `lib/history-mode.ts`" half of this decision: the + single derivation `historyMode = $derived(gameState.historyMode)` + lives directly in `+layout.svelte`, the rune split between + `currentTurn` and `viewedTurn` lives in `GameStateStore`, and + no separate module is introduced. See Phase 26 decisions for + the rationale. 7. **Empty-state copy is `order is empty` / `приказ пуст`.** The `coming soon` placeholder text is replaced; per-row delete button reads `delete` / `удалить`. @@ -2805,24 +2809,116 @@ Targeted tests: ## Phase 26. History Mode -Status: pending. +Status: pending (awaiting local-ci verification). Goal: let the user navigate to past turns and view all data as it was, with no order composition allowed. -Artifacts: +Decisions baked in during implementation: -- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn - counter expansion: popover (desktop) / bottom-sheet (mobile) listing - recent turns and a search field for jumping to a turn number -- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every - view's data source: when active, all `state-binding`, table, report, - inspector, and map sources read from the historical snapshot for the - selected turn -- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner - reading `Viewing turn N · read-only` with a `Return to current turn` - action -- order tab hidden in history mode (already prepared in Phase 12) +1. **History state lives in `GameStateStore`, no separate module.** + The Phase 12 plan-line "introduce `lib/history-mode.ts`" is + superseded: the only consumer needs a one-line derivation + (`historyMode = $derived(gameState.historyMode)`), and the + project's compactness rule rejects an abstraction with no second + caller. The store ships two distinct turn runes — `currentTurn` + (server's authoritative latest, set by `setGame` / + `advanceToPending`) and `viewedTurn` (what the UI displays, set + by `viewTurn` / `returnToCurrent`) — plus the derived + `historyMode` rune that flips when `viewedTurn < currentTurn`. +2. **`OrderDraftStore` gates mutations at one chokepoint.** + `bindClient` gains an optional `getHistoryMode: () => boolean` + alongside the existing `getCurrentTurn`; `add` / `remove` / + `move` return early when it reports `true`. Every Phase 14–22 + inspector that calls `orderDraft.add(...)` becomes inert in + history mode without per-component edits. +3. **Turn navigator UX.** Header replaces the static `turn N` text + with `← turn N →`: arrows step ±1 (disabled at `0` and + `currentTurn`), the middle button opens a dropdown of every + turn `Turn #0`…`Turn #currentTurn` with the current row carrying + a badge. No free-text input. Desktop uses an absolute popover + under the header; mobile reuses `view-menu.svelte`'s fixed- + drawer pattern (no new primitive). Selecting the current row + routes through `returnToCurrent()` so the "leave history" path + has one canonical entry. +4. **History is ephemeral across reloads.** `last-viewed-turn` is + written only when `viewedTurn === currentTurn`; historical + excursions never advance the resume bookmark. Page reload exits + history mode. The visibility-refresh listener is a no-op while + `historyMode` is true so a tab-focus event cannot silently kick + the user back onto the live turn. Push events (Phase 24) continue + to deliver new-turn notifications, so the pending-turn toast + still appears. +5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}` + namespace stores past-turn reports; `viewTurn(N)` reads cache + first and falls back to the network on miss. Past turns are + immutable so the cache has no TTL and no eviction. The current + turn deliberately skips the cache (it is mutable until the next + tick). +6. **Order overlay short-circuits in history mode.** + `RenderedReportSource.report` returns the raw server snapshot + instead of running `applyOrderOverlay`: the draft is composed + against the current turn, projecting it onto a past report would + render fictional intent. +7. **`game.shell.headline` removed.** The Phase 11 i18n key that + formatted `{race} @ {game}, turn {turn}` is deleted; the header + composes `race @ game` in plain text and delegates `turn N` to + `turn-navigator.svelte`. The existing `game-shell-headline` + testid moves to the `.left` wrapper so e2e specs that match + `toContainText("turn N")` continue to find the substring inside + the navigator's button. + +Artifacts (delivered): + +- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune, + derived `historyMode` rune, `viewTurn(turn)` / + `returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })` + refactor that gates `last-viewed-turn` writes, `readReport` cache + layer over the `game-history` namespace, visibility-refresh + short-circuit in history mode, `initSynthetic` keeps + `currentTurn === viewedTurn`. +- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts + `getHistoryMode`, `add` / `remove` / `move` no-op when active. +- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short- + circuit when `gameState.historyMode === true`. +- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header + triplet `← turn N →` + dropdown popover / drawer, reuses + `view-menu.svelte`'s outside-click / Escape pattern. +- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky + read-only banner under the header with a `Return to current turn` + action. +- `ui/frontend/src/lib/header/header.svelte` — embeds + `` next to the race-and-game identity span; + drops the static turn portion. +- `ui/frontend/src/routes/games/[id]/+layout.svelte` — + `historyMode` derived rune, `getHistoryMode` passed to + `orderDraft.bindClient`, `` mounted between + header and body. +- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new + `game.shell.history.*` and `game.shell.turn.*` keys; the now- + unused `game.shell.headline` entry is removed. +- `ui/docs/storage.md` — `game-history` namespace row; also adds + the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc + gap). +- `ui/docs/game-state.md` — current/viewed-turn rune table, the + new History mode section. +- `ui/docs/navigation.md` — describes the navigator, the read-only + banner, and the `historyMode` derivation wiring. +- `ui/docs/order-composer.md` — notes the mutation gate, the + overlay short-circuit, and the cross-doc references. +- Vitest: `tests/game-state.test.ts` extended with `viewTurn` / + `returnToCurrent` / `historyMode` derivation / cache hit / + visibility-refresh short-circuit / resume-from-stale-bookmark + flips; `tests/order-draft.test.ts` extended with the history- + mode gate cases; `tests/turn-navigator.test.ts` and + `tests/history-banner.test.ts` (new) cover the components in + isolation. +- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the + full chrome flow against `/games//table/planets`. The map + view is deliberately avoided because the Pixi renderer can + monopolise the headless Chromium main thread long enough to let + the `toContainText` poll find stale "turn ?" content; the table + view exercises the same wiring without that rendering tail. Dependencies: Phases 11, 12, 23. @@ -2834,15 +2930,20 @@ Acceptance criteria: available; - returning to the current turn restores live data and re-shows the order tab with the prior draft intact (state preservation); -- all UI views (map, tables, report, battle, mail) work in history - mode. +- battle / mail stub views still render correctly while the + read-only banner is visible (Phases 27 / 28 will replace the + stubs with real implementations; the wiring is sufficient + today). Targeted tests: -- Vitest unit tests for `history-mode` toggle and per-view source - selection; -- Playwright e2e: enter history mode, navigate three views, return, - confirm the order draft survived. +- Vitest unit tests for current/viewed turn rune split, view-turn + cache behaviour, visibility-refresh short-circuit, order-draft + history-mode gate, turn-navigator interactions, history-banner + rendering / action; +- Playwright e2e: enter history mode via arrow, navigate via + dropdown, return via banner action, confirm the order draft + survives the round-trip. ## Phase 27. Battle Viewer diff --git a/ui/docs/game-state.md b/ui/docs/game-state.md index d8c051c..5cb6f53 100644 --- a/ui/docs/game-state.md +++ b/ui/docs/game-state.md @@ -37,6 +37,10 @@ The store exposes: | `gameId` | `string` | active game id | | `status` | `idle / loading / ready / error` | current lifecycle state | | `report` | `GameReport \| null` | latest decoded report, `null` until first fetch | +| `currentTurn` | `number` | server's authoritative current turn (live snapshot) | +| `viewedTurn` | `number` | turn whose snapshot is in `report`; equals `currentTurn` in live mode | +| `historyMode` | `boolean` (derived) | true while `status === "ready"` and `viewedTurn < currentTurn` | +| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened | | `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | | `error` | `string \| null` | localised error message when `status === "error"` | @@ -45,8 +49,15 @@ The store exposes: - Phase 11 surfaces only the planet subset of the report. Later phases extend `GameReport` and `decodeReport` as their slice of the wire lands (ships, fleets, sciences, routes, battles, mail). -- Phase 26 wires history mode through `setTurn(turn)`. The store - already supports it; the navigator UI is what is missing. +- Phase 26 splits `currentTurn` from the turn whose snapshot is + displayed (`viewedTurn`) and adds `viewTurn(turn)` / + `returnToCurrent()` for history navigation. The derived + `historyMode` rune flips automatically when `viewedTurn < + currentTurn`; the layout passes it to Phase 12's sidebar / + bottom-tabs wiring (which hides the order tab) and to + `OrderDraftStore.bindClient` (which gates `add` / `remove` / + `move`). See "History mode" below for the cache and refresh + rules. - Phase 24 replaces the tab-focus refresh with push-event-driven refreshes; the visibility listener stays as a fallback for background tabs that miss a push. @@ -88,17 +99,59 @@ result can resolve back to a planet without an extra lookup table. ## Refresh discipline -`refresh()` re-fetches the same turn snapshot. It is called by the -`visibilitychange` handler when `document.visibilityState === -"visible"` and the store is already in `ready` state. The map view's -mount effect skips a re-render when the new snapshot's turn matches -the previously-mounted turn (and the wrap mode is unchanged), so a -no-op refresh does not flicker the canvas. +`refresh()` re-fetches the current-turn snapshot. It is called by +the `visibilitychange` handler when `document.visibilityState === +"visible"` and the store is already in `ready` state. The map +view's mount effect skips a re-render when the new snapshot's turn +matches the previously-mounted turn (and the wrap mode is +unchanged), so a no-op refresh does not flicker the canvas. -`setTurn(turn)` is the entry point for Phase 26 history mode: -calling it on a different turn loads that snapshot and the same -mount effect re-creates the renderer with the new world. +In history mode `refresh()` is a no-op — forcing a reload would +silently bump the user back onto the current turn while they are +intentionally viewing a past one. Push events (Phase 24) still +deliver new-turn notifications asynchronously while the user +explores history, so the pending-turn toast continues to work. `setWrapMode(mode)` writes to `Cache` and updates the rune; the map view's effect picks the change up and re-mounts the renderer with the new mode. + +## History mode + +Phase 26 lets the user step backward through the report timeline +without losing the live snapshot. The store keeps two turn runes: + +- `currentTurn` — the server's authoritative latest. Only + `setGame` and `advanceToPending` write to it. +- `viewedTurn` — the turn currently rendered. `viewTurn(N)` flips + this rune and the underlying `report` to `N` without touching + `currentTurn`. `returnToCurrent()` is a one-line wrapper that + navigates back to live. + +The derived `historyMode` rune (`status === "ready" && viewedTurn +< currentTurn`) drives every history-aware consumer: + +- the layout passes it to `Sidebar` / `BottomTabs` so the order + tab vanishes (Phase 12 prop wiring); +- the layout passes a `getHistoryMode` getter to + `OrderDraftStore.bindClient` so `add` / `remove` / `move` are + no-ops while the user is looking at a past turn; +- `RenderedReportSource` returns the raw report (no order overlay) + because the draft is composed against the current turn; +- the new `HistoryBanner` component renders the sticky "Viewing + turn N · read-only" strip when the flag is true. + +`last-viewed-turn` semantics keep their Phase 11 meaning: "the +latest turn the user was caught up on". `loadTurn` only writes the +cache row when called with `isCurrent === true` (i.e. when the +load matches `currentTurn`). Historical excursions are therefore +ephemeral: closing the tab and reopening the game resumes on the +last caught-up turn, not on the last clicked one. + +Past-turn reports are cached in the `game-history` namespace +(`{gameId}/turn/{N}` → `GameReport`). The cache is written by +`loadTurn` on every successful historical fetch and read first by +`viewTurn(N)` before falling back to the network. Past turns are +immutable, so the cache has no TTL and no eviction in Phase 26. +The current-turn snapshot is deliberately *not* cached — it is +mutable until the next engine tick. diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index ff57868..75e4cf0 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -75,17 +75,46 @@ end-to-end command flow) can set it on navigation. The Order entry is hidden when the layout's `historyMode` flag is true. Phase 12 plumbs the flag end-to-end as a prop — -`+layout.svelte` passes a constant `false` to `Sidebar`, which +`+layout.svelte` forwards a derived value to `Sidebar`, which forwards `hideOrder` to its `TabBar`; the same flag goes to `BottomTabs` so the mobile `Order` button is also suppressed. A `?sidebar=order` URL seed that arrives while the flag is true falls back to `inspector`, and an `$effect` on the sidebar resets `activeTab` away from `order` if the flag flips on mid-session. -Phase 26 introduces `lib/history-mode.ts` and replaces the constant -with the live signal; the order draft survives the toggle because + +Phase 26 wires the flag to the live history signal owned by +`GameStateStore`. The derivation lives directly in `+layout.svelte` +(`const historyMode = $derived(gameState.historyMode)`) — no +separate `lib/history-mode.ts` module ships, because the layout is +the single consumer and the project's compactness rule rejects a +one-line indirection. The order draft survives the toggle because `OrderDraftStore` lives one level above the sidebar in the layout -hierarchy. See [`order-composer.md`](order-composer.md) for the -draft-store side of the flow. +hierarchy; the same `historyMode` derivation is also fed into +`OrderDraftStore.bindClient` so inspector-driven mutations +(`add` / `remove` / `move`) become no-ops while the user is +viewing a past turn. See [`order-composer.md`](order-composer.md) +for the draft-store side of the flow and +[`game-state.md`](game-state.md) for the rune split between +`currentTurn` and `viewedTurn`. + +## Header turn navigator and history banner + +The header replaces the Phase 11 inline `turn N` text with a +`← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The +arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and +`currentTurn`); clicking the middle button opens an absolute +popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98 +px) listing every turn from `currentTurn` down to `0`. Selecting +the current-turn row routes through `gameState.returnToCurrent()`; +any other row calls `gameState.viewTurn(N)`. The popover reuses +`view-menu.svelte`'s outside-click / Escape pattern. + +`lib/header/history-banner.svelte` renders directly under the +header whenever `gameState.historyMode === true`. It shows +"Viewing turn {N} · read-only" with a "Return to current turn" +button that delegates back to `gameState.returnToCurrent()`. Both +the navigator and the banner read `gameState` through context, so +the layout is the only place where the wiring lives. ## Layout breakpoints diff --git a/ui/docs/order-composer.md b/ui/docs/order-composer.md index c2df44d..736fa6f 100644 --- a/ui/docs/order-composer.md +++ b/ui/docs/order-composer.md @@ -300,13 +300,14 @@ order composer uses the namespace. ## History mode wiring -Phase 26 introduces a global history-mode flag. The IA section -specifies that the Order tab is hidden when history mode is active — -the player is browsing a past turn snapshot, and composing commands -against an immutable snapshot would be confusing. +Phase 26 implements history mode: the user can step back through +past turns and see the report as it was. The IA section specifies +that the Order tab is hidden when history mode is active — the +player is browsing an immutable snapshot, and composing commands +against it would be confusing. Phase 12 wires the flag end-to-end as a prop. The layout owns the -flag (a constant `false` until Phase 26) and passes it to: +flag and passes it to: - `Sidebar` as `historyMode`. The sidebar forwards it to its `TabBar` as `hideOrder`. The Order entry is filtered out of the @@ -317,10 +318,31 @@ flag (a constant `false` until Phase 26) and passes it to: - `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` button is suppressed when true. +Phase 26 turns the constant into a derived value driven by +`GameStateStore.historyMode` (`viewedTurn < currentTurn` while +`status === "ready"`). The same getter is also passed into +`OrderDraftStore.bindClient` as `getHistoryMode`, which short- +circuits the `add` / `remove` / `move` mutations to a no-op while +the flag is true. This makes every Phase 14–22 inspector affordance +that calls `orderDraft.add(...)` inert in history mode without +per-component edits — the gate lives in the one chokepoint that +all callers go through. The conflict / paused banners and the +in-flight sync pipeline are untouched: they describe state that +exists independently of the user's current view. + The store itself stays alive across history-mode round-trips so -the draft survives. Phase 26 will replace the constant with the -real signal from `lib/history-mode.ts` and exercise the toggle in -its own test suite. +the draft survives the toggle. The `RenderedReportSource` overlay +(`lib/rendered-report.svelte.ts`) additionally short-circuits in +history mode: when `gameState.historyMode === true` it returns +the raw report so the map / inspector do not project pending +renames composed for the *current* turn onto a *past* report. + +See [`game-state.md`](game-state.md) for the `viewTurn` / +`returnToCurrent` API, the cache namespace +(`game-history/{gameId}/turn/{N}`), and the visibility-refresh +short-circuit; see [`navigation.md`](navigation.md) for the turn +navigator and the read-only banner that surface history mode in +the chrome. ## Testing diff --git a/ui/docs/storage.md b/ui/docs/storage.md index 4ef406e..334c0a7 100644 --- a/ui/docs/storage.md +++ b/ui/docs/storage.md @@ -112,11 +112,13 @@ wipes every namespace. Namespaces in current use: -| Namespace | Key | Value type | Owner | -|-----------------|---------------------|------------------|-----------------------------| -| `session` | `device-session-id` | `string` | Phase 7+ | -| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) | -| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) | +| Namespace | Key | Value type | Owner | +|-----------------|--------------------------------|------------------|------------------------------------| +| `session` | `device-session-id` | `string` | Phase 7+ | +| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) | +| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) | +| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) | +| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) | Later phases will add more per-feature namespaces (fixtures, lobby snapshot, etc.). The contract is namespace-strings stay scoped to diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 23ab7c2..ecf3b62 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -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 { + async viewTurn(turn: number): Promise { 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 { - return this.setTurn(this.currentTurn); + returnToCurrent(): Promise { + 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 { + 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 { + private async loadTurn( + turn: number, + opts: { isCurrent: boolean }, + ): Promise { 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 { + if (this.client === null) { + throw new Error("game-state: readReport called without client"); + } + if (!isCurrent && this.cache !== null) { + const cached = await this.cache.get( + 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 { diff --git a/ui/frontend/src/lib/header/header.svelte b/ui/frontend/src/lib/header/header.svelte index 9aeaf79..6156c5f 100644 --- a/ui/frontend/src/lib/header/header.svelte +++ b/ui/frontend/src/lib/header/header.svelte @@ -1,8 +1,10 @@ + + +{#if visible} + +{/if} + + diff --git a/ui/frontend/src/lib/header/turn-navigator.svelte b/ui/frontend/src/lib/header/turn-navigator.svelte new file mode 100644 index 0000000..ad7e346 --- /dev/null +++ b/ui/frontend/src/lib/header/turn-navigator.svelte @@ -0,0 +1,263 @@ + + + +
+ + + + {#if open} + + {/if} +
+ + diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index a9b1d9e..a5c226e 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -89,7 +89,6 @@ const en = { "lobby.error.unknown": "{message}", "game.shell.unknown": "?", - "game.shell.headline": "{race} @ {game}, turn {turn}", "game.shell.connection.online": "online", "game.shell.connection.reconnecting": "reconnecting…", "game.shell.connection.offline": "offline", @@ -104,6 +103,15 @@ const en = { "game.shell.menu.language": "language", "game.shell.menu.logout": "logout", "game.shell.coming_soon": "coming soon", + "game.shell.turn.label": "turn {turn}", + "game.shell.turn.list_item": "turn #{turn}", + "game.shell.turn.prev": "previous turn", + "game.shell.turn.next": "next turn", + "game.shell.turn.open_navigator": "open turn list", + "game.shell.turn.close_navigator": "close turn list", + "game.shell.history.viewing": "Viewing turn {turn} · read-only", + "game.shell.history.return_to_current": "Return to current turn", + "game.shell.history.current_badge": "current", "game.view.map": "map", "game.view.table": "table", "game.view.table.planets": "planets", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 2f10250..b9e170d 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -90,7 +90,6 @@ const ru: Record = { "lobby.error.unknown": "{message}", "game.shell.unknown": "?", - "game.shell.headline": "{race} @ {game}, ход {turn}", "game.shell.connection.online": "онлайн", "game.shell.connection.reconnecting": "переподключение…", "game.shell.connection.offline": "офлайн", @@ -105,6 +104,15 @@ const ru: Record = { "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(); + }); +});