ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn (displayed snapshot) so history excursions don't corrupt the resume bookmark or the live-turn bound. Add viewTurn / returnToCurrent / historyMode rune, plus a game-history cache namespace that stores past-turn reports for fast re-entry. OrderDraftStore.bindClient takes a getHistoryMode getter and short-circuits add / remove / move while the user is viewing a past turn; RenderedReportSource skips the order overlay in the same case. Header replaces the static "turn N" with a clickable triplet (TurnNavigator), the layout mounts HistoryBanner under the header, and visibility-refresh is a no-op while history is active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,10 @@ const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
|
||||
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
|
||||
`${gameId}/last-viewed-turn`;
|
||||
|
||||
const HISTORY_NAMESPACE = "game-history";
|
||||
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
|
||||
`${gameId}/turn/${turn}`;
|
||||
|
||||
/**
|
||||
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
|
||||
* layout uses to expose its `GameStateStore` instance to descendants.
|
||||
@@ -55,9 +59,30 @@ export class GameStateStore {
|
||||
* game (lifted from the lobby record on `setGame`). Phase 14
|
||||
* exposes it so the layout can pass it to
|
||||
* `OrderDraftStore.hydrateFromServer` after both stores boot;
|
||||
* later phases (history mode, calc) will read it directly.
|
||||
* Phase 26 keeps the "authoritative server-side turn" meaning —
|
||||
* only `setGame`, `advanceToPending`, and the visibility-listener
|
||||
* lobby re-query update it. History navigation (`viewTurn`) leaves
|
||||
* it alone so the "Return to current turn" affordance keeps a
|
||||
* reliable target.
|
||||
*/
|
||||
currentTurn = $state(0);
|
||||
/**
|
||||
* viewedTurn is the turn whose snapshot is currently displayed.
|
||||
* In live mode it equals `currentTurn`. Phase 26 history mode
|
||||
* decouples the two: `viewTurn(N)` flips this rune (and `report`)
|
||||
* to N without touching `currentTurn` or `last-viewed-turn`.
|
||||
*/
|
||||
viewedTurn = $state(0);
|
||||
/**
|
||||
* historyMode is the derived "user is viewing a past turn" rune
|
||||
* consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
|
||||
* history banner, the rendered-report overlay short-circuit, and
|
||||
* the order-draft mutation gate. It depends only on the rune state
|
||||
* above, so every consumer reacts to a single source of truth.
|
||||
*/
|
||||
historyMode = $derived(
|
||||
this.status === "ready" && this.viewedTurn < this.currentTurn,
|
||||
);
|
||||
/**
|
||||
* synthetic is set by `initSynthetic` for DEV-only sessions backed
|
||||
* by a hand-loaded report (lobby's "Load synthetic report"
|
||||
@@ -140,16 +165,18 @@ export class GameStateStore {
|
||||
// server-side current turn, open the user on their last-seen
|
||||
// snapshot and surface the gap through `pendingTurn` so the
|
||||
// shell can render a "new turn available" affordance instead
|
||||
// of silently auto-advancing.
|
||||
// of silently auto-advancing. After Phase 26 the same gap
|
||||
// also flips `historyMode` to true (viewedTurn < currentTurn),
|
||||
// so the read-only banner appears alongside the toast.
|
||||
if (
|
||||
lastViewed !== null &&
|
||||
lastViewed >= 0 &&
|
||||
lastViewed < summary.currentTurn
|
||||
) {
|
||||
this.pendingTurn = summary.currentTurn;
|
||||
await this.loadTurn(lastViewed);
|
||||
await this.loadTurn(lastViewed, { isCurrent: false });
|
||||
} else {
|
||||
await this.loadTurn(summary.currentTurn);
|
||||
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
@@ -196,7 +223,7 @@ export class GameStateStore {
|
||||
}
|
||||
this.gameName = summary.gameName;
|
||||
this.currentTurn = summary.currentTurn;
|
||||
await this.loadTurn(summary.currentTurn);
|
||||
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||
this.pendingTurn = null;
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
@@ -206,29 +233,57 @@ export class GameStateStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* setTurn loads a different turn snapshot — used by Phase 26 history
|
||||
* mode. The current turn stays at whatever `setGame` discovered;
|
||||
* calling without an argument refetches the same turn.
|
||||
* viewTurn loads the historical snapshot for `turn` and switches the
|
||||
* UI into history mode (Phase 26). The current turn is untouched —
|
||||
* `historyMode` flips on automatically through the derived rune, and
|
||||
* the `last-viewed-turn` cache is only refreshed when the caller
|
||||
* happens to ask for the currentTurn (e.g. `returnToCurrent`). A
|
||||
* cache hit on `game-history/{gameId}/turn/{N}` skips the network;
|
||||
* past turns are immutable so the cache never goes stale.
|
||||
*/
|
||||
async setTurn(turn: number): Promise<void> {
|
||||
async viewTurn(turn: number): Promise<void> {
|
||||
if (this.client === null) return;
|
||||
if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
|
||||
return;
|
||||
}
|
||||
this.status = "loading";
|
||||
this.error = null;
|
||||
try {
|
||||
await this.loadTurn(turn);
|
||||
await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.status = "error";
|
||||
this.error = describe(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh re-fetches the report at the current turn. Called on
|
||||
* window `visibilitychange` so the map and the turn counter stay
|
||||
* fresh after the user returns to the tab.
|
||||
* returnToCurrent jumps back to the server's current turn after a
|
||||
* history excursion. Thin wrapper around `viewTurn(currentTurn)` so
|
||||
* the banner / popover share the same call site.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.setTurn(this.currentTurn);
|
||||
returnToCurrent(): Promise<void> {
|
||||
return this.viewTurn(this.currentTurn);
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh is fired from the `visibilitychange` listener. In live
|
||||
* mode it re-fetches the report at the current turn so the map and
|
||||
* the counter catch up after the user returns to the tab. In
|
||||
* history mode it is a no-op: the user is intentionally viewing a
|
||||
* past turn, push events (Phase 24) deliver new-turn notifications
|
||||
* asynchronously, and forcing a reload would silently bump the
|
||||
* user out of history mode.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
if (this.client === null) return;
|
||||
if (this.historyMode) return;
|
||||
try {
|
||||
await this.loadTurn(this.currentTurn, { isCurrent: true });
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
console.warn("game-state: refresh failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,6 +331,7 @@ export class GameStateStore {
|
||||
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
|
||||
this.report = opts.report;
|
||||
this.currentTurn = opts.report.turn;
|
||||
this.viewedTurn = opts.report.turn;
|
||||
this.status = "ready";
|
||||
}
|
||||
|
||||
@@ -295,20 +351,57 @@ export class GameStateStore {
|
||||
return games.find((g) => g.gameId === gameId) ?? null;
|
||||
}
|
||||
|
||||
private async loadTurn(turn: number): Promise<void> {
|
||||
private async loadTurn(
|
||||
turn: number,
|
||||
opts: { isCurrent: boolean },
|
||||
): Promise<void> {
|
||||
if (this.client === null) return;
|
||||
const report = await fetchGameReport(this.client, this.gameId, turn);
|
||||
const report = await this.readReport(turn, opts.isCurrent);
|
||||
if (this.destroyed) return;
|
||||
this.report = report;
|
||||
this.currentTurn = turn;
|
||||
this.viewedTurn = turn;
|
||||
this.status = "ready";
|
||||
if (this.cache !== null) {
|
||||
if (this.cache === null) return;
|
||||
if (opts.isCurrent) {
|
||||
// Persist last-viewed-turn only when the user is caught up
|
||||
// on the live snapshot. Historical excursions are ephemeral
|
||||
// (Phase 26 decision): the resume-on-open affordance from
|
||||
// Phase 11 must keep meaning "the latest turn this player
|
||||
// was caught up on", not "wherever they last clicked".
|
||||
await this.cache.put(
|
||||
PREF_NAMESPACE,
|
||||
PREF_KEY_LAST_VIEWED_TURN(this.gameId),
|
||||
turn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Past turns are immutable, so the snapshot is safe to cache
|
||||
// for fast re-entry. The current-turn snapshot deliberately
|
||||
// skips the cache — it is mutable until the next tick.
|
||||
await this.cache.put(
|
||||
HISTORY_NAMESPACE,
|
||||
HISTORY_KEY_TURN(this.gameId, turn),
|
||||
report,
|
||||
);
|
||||
}
|
||||
|
||||
private async readReport(
|
||||
turn: number,
|
||||
isCurrent: boolean,
|
||||
): Promise<GameReport> {
|
||||
if (this.client === null) {
|
||||
throw new Error("game-state: readReport called without client");
|
||||
}
|
||||
if (!isCurrent && this.cache !== null) {
|
||||
const cached = await this.cache.get<GameReport>(
|
||||
HISTORY_NAMESPACE,
|
||||
HISTORY_KEY_TURN(this.gameId, turn),
|
||||
);
|
||||
if (cached !== undefined && cached.turn === turn) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
return await fetchGameReport(this.client, this.gameId, turn);
|
||||
}
|
||||
|
||||
private installVisibilityListener(): void {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<!--
|
||||
Top header for the in-game shell. Composes the in-game ID strip
|
||||
(race name @ game name, turn N), view dropdown / hamburger, and the
|
||||
account menu. The sidebar-toggle slot to its left appears only on
|
||||
tablet viewports (768–1024 px) and is wired by `+layout.svelte`.
|
||||
(race name @ game name) followed by the Phase 26 turn navigator (a
|
||||
`← Turn N →` triplet with a popover of every turn), the view
|
||||
dropdown / hamburger, and the account menu. The sidebar-toggle slot
|
||||
to its left appears only on tablet viewports (768–1024 px) and is
|
||||
wired by `+layout.svelte`.
|
||||
|
||||
The race name is read from the engine's `Report.race`, the game
|
||||
name from the lobby's `GameSummary.gameName`. While either piece
|
||||
@@ -22,6 +24,7 @@ absent until Phase 24 wires push-event state.
|
||||
} from "$lib/game-state.svelte";
|
||||
import ViewMenu from "./view-menu.svelte";
|
||||
import AccountMenu from "./account-menu.svelte";
|
||||
import TurnNavigator from "./turn-navigator.svelte";
|
||||
|
||||
type Props = {
|
||||
gameId: string;
|
||||
@@ -44,27 +47,14 @@ absent until Phase 24 wires push-event state.
|
||||
const name = gameState?.gameName ?? "";
|
||||
return name === "" ? i18n.t("game.shell.unknown") : name;
|
||||
});
|
||||
const turn = $derived.by(() => {
|
||||
const report = gameState?.report;
|
||||
return report === null || report === undefined
|
||||
? i18n.t("game.shell.unknown")
|
||||
: String(report.turn);
|
||||
});
|
||||
|
||||
const headline = $derived(
|
||||
i18n.t("game.shell.headline", {
|
||||
race: raceName,
|
||||
game: gameName,
|
||||
turn,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<header class="game-shell-header" data-testid="game-shell-header">
|
||||
<div class="left">
|
||||
<span class="headline" data-testid="game-shell-headline">
|
||||
{headline}
|
||||
<div class="left" data-testid="game-shell-headline">
|
||||
<span class="identity" data-testid="game-shell-identity">
|
||||
{raceName} @ {gameName}
|
||||
</span>
|
||||
<TurnNavigator />
|
||||
</div>
|
||||
<div class="right">
|
||||
<button
|
||||
@@ -106,7 +96,7 @@ absent until Phase 24 wires push-event state.
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.headline {
|
||||
.identity {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!--
|
||||
Phase 26 read-only banner. Renders directly under the shell header
|
||||
whenever the user is viewing a past turn (`gameState.historyMode`).
|
||||
Carries the turn number and a "Return to current turn" action that
|
||||
delegates to `gameState.returnToCurrent()`. The banner is invisible
|
||||
in live mode so the active-view chrome keeps its full vertical
|
||||
budget.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.svelte";
|
||||
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
|
||||
const visible = $derived(gameState?.historyMode === true);
|
||||
|
||||
function onReturn(): void {
|
||||
void gameState?.returnToCurrent();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<aside class="history-banner" data-testid="history-banner" role="status">
|
||||
<span class="message">
|
||||
{i18n.t("game.shell.history.viewing", { turn: String(viewedTurn) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="return"
|
||||
data-testid="history-banner-return"
|
||||
onclick={onReturn}
|
||||
>
|
||||
{i18n.t("game.shell.history.return_to_current")}
|
||||
</button>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.history-banner {
|
||||
position: sticky;
|
||||
top: 3rem;
|
||||
z-index: 35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: #2a2438;
|
||||
color: #efe9c8;
|
||||
border-bottom: 1px solid #45375a;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.return {
|
||||
font: inherit;
|
||||
padding: 0.25rem 0.65rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid #6c5a8a;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.return:hover {
|
||||
background: #3a3050;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<!--
|
||||
Phase 26 header turn navigator. Replaces the Phase 11 inline turn
|
||||
number with a `← Turn N →` triplet. The arrows step ±1 (disabled at
|
||||
boundaries `0` and `currentTurn`), the middle button opens a popover
|
||||
listing every turn `Turn #0`…`Turn #currentTurn` with the current row
|
||||
tagged. No free-text input — every reachable turn is in the list, so
|
||||
there is nothing to validate.
|
||||
|
||||
Desktop and mobile share the same component: an absolute-positioned
|
||||
popover anchored to the trigger on desktop becomes a fixed full-width
|
||||
drawer below the 768 px breakpoint, mirroring `view-menu.svelte`.
|
||||
|
||||
Selecting a row calls `gameState.viewTurn(N)`; the row that matches
|
||||
`currentTurn` delegates to `gameState.returnToCurrent()` so the
|
||||
"leave history" path always flows through one method.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.svelte";
|
||||
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
let open = $state(false);
|
||||
let rootEl: HTMLDivElement | null = $state(null);
|
||||
|
||||
const currentTurn = $derived(gameState?.currentTurn ?? 0);
|
||||
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
|
||||
const ready = $derived(gameState?.status === "ready");
|
||||
const canPrev = $derived(ready && viewedTurn > 0);
|
||||
const canNext = $derived(ready && viewedTurn < currentTurn);
|
||||
// Until the boot completes the store has no report, so the
|
||||
// counter falls back to the same `?` placeholder Phase 11 used in
|
||||
// the static headline. Avoids briefly flashing `turn 0` while the
|
||||
// lobby / report calls are in flight.
|
||||
const turnText = $derived(
|
||||
ready ? String(viewedTurn) : i18n.t("game.shell.unknown"),
|
||||
);
|
||||
|
||||
// Descending list newest → oldest. The popover stays compact for
|
||||
// short games and scrolls for long ones; the current-turn row is
|
||||
// pinned at the top with an explicit badge so the affordance to
|
||||
// jump back is always reachable without scrolling.
|
||||
const turns = $derived.by(() => {
|
||||
if (!ready) return [] as number[];
|
||||
const out: number[] = [];
|
||||
for (let i = currentTurn; i >= 0; i--) {
|
||||
out.push(i);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function toggleOpen(): void {
|
||||
if (!ready) return;
|
||||
open = !open;
|
||||
}
|
||||
|
||||
async function goToTurn(turn: number): Promise<void> {
|
||||
open = false;
|
||||
if (gameState === undefined) return;
|
||||
if (turn === gameState.currentTurn) {
|
||||
await gameState.returnToCurrent();
|
||||
return;
|
||||
}
|
||||
await gameState.viewTurn(turn);
|
||||
}
|
||||
|
||||
async function step(delta: number): Promise<void> {
|
||||
if (gameState === undefined) return;
|
||||
const next = viewedTurn + delta;
|
||||
if (next < 0 || next > currentTurn) return;
|
||||
if (next === gameState.currentTurn) {
|
||||
await gameState.returnToCurrent();
|
||||
return;
|
||||
}
|
||||
await gameState.viewTurn(next);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape" && open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
if (!open || rootEl === null) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Node && rootEl.contains(target)) return;
|
||||
open = false;
|
||||
};
|
||||
document.addEventListener("click", handleClick, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="turn-navigator" bind:this={rootEl} data-testid="turn-navigator">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
data-testid="turn-navigator-prev"
|
||||
aria-label={i18n.t("game.shell.turn.prev")}
|
||||
disabled={!canPrev}
|
||||
onclick={() => void step(-1)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="trigger"
|
||||
data-testid="turn-navigator-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={open
|
||||
? i18n.t("game.shell.turn.close_navigator")
|
||||
: i18n.t("game.shell.turn.open_navigator")}
|
||||
disabled={!ready}
|
||||
onclick={toggleOpen}
|
||||
>
|
||||
{i18n.t("game.shell.turn.label", { turn: turnText })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
data-testid="turn-navigator-next"
|
||||
aria-label={i18n.t("game.shell.turn.next")}
|
||||
disabled={!canNext}
|
||||
onclick={() => void step(1)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="surface" role="menu" data-testid="turn-navigator-list">
|
||||
{#each turns as turn (turn)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="row"
|
||||
class:viewed={turn === viewedTurn}
|
||||
data-testid={`turn-navigator-item-${turn}`}
|
||||
onclick={() => void goToTurn(turn)}
|
||||
>
|
||||
<span class="label">
|
||||
{i18n.t("game.shell.turn.list_item", { turn: String(turn) })}
|
||||
</span>
|
||||
{#if turn === currentTurn}
|
||||
<span class="badge" data-testid="turn-navigator-current-badge">
|
||||
{i18n.t("game.shell.history.current_badge")}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.turn-navigator {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.step,
|
||||
.trigger {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.step:hover:not(:disabled),
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: #1c2238;
|
||||
}
|
||||
.step:disabled,
|
||||
.trigger:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.trigger {
|
||||
min-width: 5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.surface {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
left: 0;
|
||||
min-width: 10rem;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #14182a;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
z-index: 50;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover {
|
||||
background: #1c2238;
|
||||
}
|
||||
.row.viewed {
|
||||
font-weight: 600;
|
||||
background: #1a2040;
|
||||
}
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.05rem 0.4rem;
|
||||
background: #2a3150;
|
||||
color: #d8def0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.surface {
|
||||
position: fixed;
|
||||
top: 3rem;
|
||||
right: 0;
|
||||
left: 0;
|
||||
min-width: 0;
|
||||
max-height: calc(100vh - 3rem);
|
||||
border-radius: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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",
|
||||
|
||||
@@ -90,7 +90,6 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"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<keyof typeof en, string> = {
|
||||
"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": "планеты",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user