Compare commits

...
4 Commits
Author SHA1 Message Date
kamilkrzywda 6b167cb435 hud chrome: remove Map window, pin top bar, dockable left/right panels (v0.5.0)
CI / build + unit + e2e (pull_request) Successful in 2m18s
2026-10-11 17:40:01 +00:00
kamilkrzywda 762c120bbd Merge pull request 'v0.4.0 — fix queue-add race + per-item remove button' (#14) from queue-add-fix into master
CI / build + unit + e2e (push) Successful in 1m53s
2026-10-11 18:18:47 +02:00
kamilkrzywda cfcdd8b8a8 docs: clarify Gitea issue convention (reference filed issues; no redundant changelog issue)
CI / build + unit + e2e (pull_request) Successful in 1m57s
2026-10-11 16:15:00 +00:00
kamilkrzywda ef27b59762 0.4.0 — fix queue-add race + per-item remove button (#10, #11) 2026-10-11 16:09:29 +00:00
16 changed files with 805 additions and 151 deletions
+37 -6
View File
@@ -14,7 +14,7 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
## Change process (mandatory) ## Change process (mandatory)
1. **Small, incremental, well-tested changes** — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel). 1. **Small, incremental, well-tested changes** — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel).
2. **Gitea issue first** — file an issue on Gitea (`https://git.kkhost.pl`, repo `OpenCode/History`) describing the change (title, scope, acceptance criteria) before or alongside creating the branch. The issue is the single source of intent; the PR references it. 2. **Gitea issue + `Fixes #N` in the PR** — the PR references (and, via `Fixes #N`, closes) the Gitea issue(s) the work addresses (`https://git.kkhost.pl`, repo `OpenCode/History`). If the user has already filed issue(s) for the change, reference those directly in the PR — no separate changelog issue is created; only file a fresh issue when the work has none pre-filed (e.g. a fix originating purely from chat feedback). `CHANGELOG.md` is always the durable changelog of what shipped.
3. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea; reference the issue in the PR description. 3. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea; reference the issue in the PR description.
4. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed). 4. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed).
5. **Version bump** — the version is updated in `package.json`. 5. **Version bump** — the version is updated in `package.json`.
@@ -51,10 +51,10 @@ history/
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/) │ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
├── shared/ # NO runtime deps — the single source of truth ├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/Jump request types + pure helpers │ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
├── server/ ├── server/
│ ├── index.ts # Express: /api/* (action|diplomacy|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR │ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / advisor │ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config │ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER │ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color) │ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
@@ -74,8 +74,9 @@ history/
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens ├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight ├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list + "Jump forward" ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first) ├── EventFeed.tsx # scrolling event log (newest first)
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
├── StatBar.tsx ├── StatBar.tsx
└── ui/ # 14 shadcn/ui primitives └── ui/ # 14 shadcn/ui primitives
``` ```
@@ -87,7 +88,7 @@ history/
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently | | `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` | | `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 | | `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests in `server/` (19 tests) | | `npm test` | vitest unit tests in `server/` (39 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs | | `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config | | `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) | | `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
@@ -117,6 +118,17 @@ history/
sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, and sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, and
advances the turn. An empty round (no items queued) is allowed and lets the world advance on its advances the turn. An empty round (no items queued) is allowed and lets the world advance on its
own. This design reduces LLM call frequency and makes each call a coherent batch of work. own. This design reduces LLM call frequency and makes each call a coherent batch of work.
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
trash button per row with a confirm dialog); like the adds it never advances the turn.
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
and legacy games without the field are treated as version 0. ALL state ingestion on the client —
SSE `state`/`turn` events, REST responses (add/remove/jump), and game loads — flows through the
single `applyState` in `App.tsx`: a *different* game id is always adopted, the *same* game only
when the incoming version is not older than the current one (compared inside the functional
`setGame` updater, so it can't act on a stale closure). This is what stops a late-arriving stale
SSE snapshot from clobbering the UI (see Gotchas, #10).
- **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first - **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first
balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries
once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic
@@ -131,6 +143,15 @@ history/
the player is allied with the first other nation (by key order in the `nations` record), and the the player is allied with the first other nation (by key order in the `nations` record), and the
date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub
deterministic. deterministic.
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
on resize and always floats above the other windows). The **dossier / event-feed / action**
panels are free-floating by default (the same first-run positions as before) but can each be
**docked** into a full-height left or right pane; the choice is persisted in `localStorage`
(`history.floatwin.v1.dock`) and restored on reload, so the default "float" reproduces the
original layout until the user docks something. The **legend** stays a small floating widget (not
dockable). There is **no "Map" window** — the map is zoomed/panned directly (wheel/trackpad +
drag).
- Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber - Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`. primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.
@@ -170,6 +191,16 @@ history/
## Gotchas ## Gotchas
- **Stale SSE snapshots can arrive *after* a fresher REST response — the version guard is what
keeps the UI honest (#10).** The SSE `state` event sent on connect/resync is a snapshot taken at
the moment the request is handled, and the SSE stream and the REST responses ride different
connections (Vite proxy in dev, Traefik in prod) with no delivery-order guarantee. A snapshot
taken just before a mutation is saved (e.g. the on-connect resync racing a quick "Add action")
can reach the browser *after* that mutation's REST response. Pre-v0.4.0 the client applied any
incoming state unconditionally, so a just-queued item could vanish ("add one: nothing happened,
add another: both appears" — issue #10). Now every state carries a monotonic `version` and
`App.tsx`'s `applyState` drops same-game states whose version is older than the current one.
Don't add unguarded `setGame` calls or remove the version comparison.
- **502s were the reasoning model exhausting its output budget.** The original client sent only - **502s were the reasoning model exhausting its output budget.** The original client sent only
`max_tokens: 4000`; `qwen3.8-27b`'s chain-of-thought alone often exceeded 4000 tokens, so `max_tokens: 4000`; `qwen3.8-27b`'s chain-of-thought alone often exceeded 4000 tokens, so
`content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns), `content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns),
+15
View File
@@ -2,6 +2,21 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## v0.5.0 — 2026-10-11 17:36
- **HUD revamp** — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead `MapApi`/`onMapReady` map plumbing is removed from `WorldMap`); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new `pinned` mode on `FloatingWindow` — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new `dock`/`onDockChange` mode on `FloatingWindow`, with small left/right/float controls in each title bar, choice persisted in `localStorage` and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
- **`FloatingWindow`** — new optional props: `pinned` (top-center fixed bar), `dock` (`'left'`/`'right'` full-height columns) and `onDockChange` (renders the dock controls and reports the mode), plus `dataTestId` (a `data-testid` on the window root for e2e). `anchor` is now optional (defaults to `top-center`).
- **New `data-testid`s** — `top-bar` (pinned header), `panel-dossier` / `panel-feed` / `panel-action` (per-window roots), and `dock-left` / `dock-right` / `dock-float` (per-window dock controls).
- **New localStorage key** — `history.floatwin.v1.dock` (per-id dock mode); mirrors the existing `history.floatwin.v1.collapsed`.
- **E2E** — new "HUD revamp" suite: asserts the Map window and its zoom buttons are gone, the top bar is pinned and horizontally centered a few px from the top (and doesn't move under a drag attempt), the dossier docks to the left edge (full-height) and the feed to the right edge, and a dock choice survives a page reload — all deterministic under `LLM_STUB=1` (11 tests, up from 9).
## v0.4.0 — 2026-10-11 16:02
- **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional `setGame` updater, so it is stale-closure-proof).
- **New: remove button for queued items (#11).** Each pending-queue row in "Your move" has a small trash-icon button that opens a shadcn `Dialog` confirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the new `POST /api/games/:id/queue/remove` (body `{ index }`) — same fast save → broadcast → respond order as the add routes, no LLM, no turn advance; invalid indices get a 400. The client `api.removeQueueItem(id, index)` is version-gated like every other state update, so the list can never regress after a removal.
- **Unit tests** — new `removeRoundItem` tests (valid removal + version bump, out-of-bounds/non-integer → `null`), version-bump assertions for `queueRoundItem`/`applyTurnResult`/stub turn, a legacy-no-version case, and version round-trip in `db.test.ts` (39 tests, up from 35).
- **E2E** — new "queue add is never clobbered by a stale SSE snapshot" test (one add appears immediately, still there after a 1s grace period for late SSE events, version strictly increases across adds, queue survives a full page reload) and a "remove with confirmation" test (cancel keeps the item, confirm removes it, no turn advance, version persisted) — all deterministic under `LLM_STUB=1`.
## v0.3.0 — 2026-10-11 15:29 ## v0.3.0 — 2026-10-11 15:29
- **Deep-linking** — the active game's id now lives in the URL (`#/game/<id>`), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3. - **Deep-linking** — the active game's id now lives in the URL (`#/game/<id>`), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.
+234
View File
@@ -181,6 +181,142 @@ test.describe('History — save / resume across a page refresh', () => {
}); });
}); });
/**
* Queue reliability — issue #10. A queued item must appear in the pending list
* right after its REST response AND stay there: before the fix, a stale SSE
* `state` snapshot (the on-connect resync taken just before the add was saved,
* carrying no freshness marker) could arrive after the REST response and
* clobber it — "add one: nothing happens, add another: both appear". The fix
* gives every mutation a monotonic `GameState.version` and the client applies
* an incoming state only when it is not older than the current one.
*/
test.describe('History — queue add is never clobbered by a stale SSE snapshot (#10)', () => {
test('one added action appears immediately and survives late SSE snapshots', async ({
page,
}) => {
test.setTimeout(90000);
const id = await createUkGame(page);
const origin = await page.evaluate(() => location.origin);
// Game screen at turn 1, nothing queued yet.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('pending-empty')).toBeVisible();
// Queue exactly ONE action and assert it appears immediately (turn/date
// unchanged, no events yet).
await page.getByTestId('action-textarea').fill('Hold the line at the channel');
const [addResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const addBody = (await addResp.json()) as { game: { version: number; pending: unknown[] } };
expect(addBody.game.pending).toHaveLength(1);
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
// Let any in-flight / late SSE snapshot (on-connect resync, auto-reconnect)
// have its chance to arrive. Under the stub these settle in milliseconds;
// a second is generous. Before the version fix, a stale one could clobber
// the just-added item here.
await page.waitForTimeout(1000);
// The item is still there — the stale snapshot was dropped, not applied.
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Protocol-level check: a later mutation (a second queued item) must carry
// a strictly higher version than the one we just saw.
await page.getByTestId('action-textarea').fill('Scramble the night fighters');
const [add2Resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const add2Body = (await add2Resp.json()) as { game: { version: number } };
expect(add2Body.game.version).toBeGreaterThan(addBody.game.version);
await expect(page.getByTestId('pending-item')).toHaveCount(2);
// The game persisted with a version survives a full page reload.
await page.goto(`${origin}/#/game/${id}`);
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('pending-item')).toHaveCount(2, { timeout: 15000 });
});
});
/**
* Per-item removal — issue #11. Each queued row has a small trash button that
* opens a confirmation dialog; confirming removes the item server-side
* (POST /api/games/:id/queue/remove, no turn advance) and cancelling keeps it.
*/
test.describe('History — remove a queued item with confirmation (#11)', () => {
test('cancel keeps the item; confirm removes it', async ({ page }) => {
test.setTimeout(90000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Queue one action.
await page.getByTestId('action-textarea').fill('Open the back channel with Vichy');
await page.getByTestId('add-action').click();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
// Trash icon → confirmation dialog.
await page.getByTestId('remove-queue-item').click();
const dialog = page.getByTestId('remove-confirm-dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('Open the back channel with Vichy');
// Cancel: the item stays queued.
await page.getByTestId('remove-cancel').click();
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Open again and confirm: the item is removed server-side.
await page.getByTestId('remove-queue-item').click();
await expect(dialog).toBeVisible();
const [removeResp] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname === `/api/games/${id}/queue/remove`,
),
page.getByTestId('remove-confirm').click(),
]);
const removeBody = (await removeResp.json()) as { game: { version: number; pending: unknown[] } };
expect(removeBody.game.pending).toHaveLength(0);
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-empty')).toBeVisible();
await expect(page.getByTestId('title-queued')).not.toBeVisible();
// Still turn 1, no events — removing never advances the round.
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The bumped version is persisted with the game (it survives a reload).
const after = await page.evaluate(
async (gameId) =>
((await fetch(`/api/games/${gameId}`)).json()) as Promise<{ game: { version: number } }>,
id,
);
expect(after.game.version).toBe(removeBody.game.version);
});
});
/** /**
* URL deep-linking (issue #3). The active game's id lives in the URL fragment: * URL deep-linking (issue #3). The active game's id lives in the URL fragment:
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to * home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
@@ -283,3 +419,101 @@ test.describe('History — URL deep-linking (#/game/<id>)', () => {
await waitHash(page, '#/'); await waitHash(page, '#/');
}); });
}); });
/**
* HUD revamp (issues #4 and #5). The floating "Map" zoom window is gone — the
* full-bleed map is driven directly by wheel/trackpad + drag. The top bar is a
* *pinned* element: fixed, non-movable, centered at the top a few px down. The
* dossier / feed / action panels can each be docked into a full-height left or
* right pane (persisted in `localStorage`); the legend stays a small floating
* widget.
*/
test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map window)', () => {
test('Map window is gone; top bar is pinned top-center; panels dock left/right', async ({
page,
}) => {
test.setTimeout(90000);
await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
const vw = page.viewportSize()!.width;
const vh = page.viewportSize()!.height;
// --- #4: the "Map" zoom window is gone --------------------------------
// No zoom window element, and no zoom-in/zoom-out buttons anywhere on the
// game screen.
await expect(page.locator('[data-float-window="zoom"]')).toHaveCount(0);
await expect(page.getByTestId('zoom-in')).toHaveCount(0);
await expect(page.getByTestId('zoom-out')).toHaveCount(0);
// --- #5a: the top bar is pinned, centered at the top ------------------
const topBar = page.getByTestId('top-bar');
await expect(topBar).toBeVisible();
const barBox = (await topBar.boundingBox())!;
// Horizontally centered: x ≈ (viewportWidth − width) / 2 …
expect(Math.abs(barBox.x - (vw - barBox.width) / 2)).toBeLessThanOrEqual(3);
// …and a few px down from the top edge (y ≈ 8).
expect(barBox.y).toBeGreaterThanOrEqual(6);
expect(barBox.y).toBeLessThanOrEqual(10);
// A pointer-drag attempted on the pinned bar must not move it (it has no
// drag handlers; the map behind it never receives a mousedown).
await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2);
await page.mouse.down();
await page.mouse.move(barBox.x + barBox.width / 2 + 60, barBox.y + barBox.height / 2 + 40, {
steps: 6,
});
await page.mouse.up();
const barBoxAfter = (await topBar.boundingBox())!;
expect(Math.abs(barBoxAfter.x - barBox.x)).toBeLessThanOrEqual(1);
expect(Math.abs(barBoxAfter.y - barBox.y)).toBeLessThanOrEqual(1);
// --- #5b: the dossier docks into a full-height left pane --------------
const dossier = page.getByTestId('panel-dossier');
await dossier.getByTestId('dock-left').click();
const dBox = (await dossier.boundingBox())!;
// Pinned to the left edge, filling the viewport height.
expect(dBox.x).toBeLessThanOrEqual(2);
expect(dBox.y).toBeLessThanOrEqual(2);
expect(Math.abs(dBox.height - vh)).toBeLessThanOrEqual(3);
expect(Math.abs(dBox.width - 300)).toBeLessThanOrEqual(3);
// --- #5b: the event feed docks into a full-height right pane ----------
const feed = page.getByTestId('panel-feed');
await feed.getByTestId('dock-right').click();
const fBox = (await feed.boundingBox())!;
// Pinned to the right edge: its right side sits on the viewport edge.
expect(Math.abs(fBox.x + fBox.width - vw)).toBeLessThanOrEqual(3);
expect(Math.abs(fBox.height - vh)).toBeLessThanOrEqual(3);
// The "Your move" deck is still a floating panel (we never docked it).
await expect(page.getByTestId('panel-action')).toBeVisible();
});
test('a dock choice survives a page reload (persisted in localStorage)', async ({
page,
}) => {
test.setTimeout(90000);
await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
const vh = page.viewportSize()!.height;
// Dock the dossier into the left pane.
const dossier = page.getByTestId('panel-dossier');
await dossier.getByTestId('dock-left').click();
const before = (await dossier.boundingBox())!;
expect(before.x).toBeLessThanOrEqual(2);
expect(Math.abs(before.height - vh)).toBeLessThanOrEqual(3);
// Reload: the persisted dock choice must restore the docked layout, and the
// deep link restores the game.
await page.reload();
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1', { timeout: 20000 });
const after = (await page.getByTestId('panel-dossier').boundingBox())!;
expect(after.x).toBeLessThanOrEqual(2);
expect(Math.abs(after.height - vh)).toBeLessThanOrEqual(3);
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.3.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+6
View File
@@ -22,6 +22,7 @@ function makeState(id: string): GameState {
}, },
events: [], events: [],
pending: [], pending: [],
version: 1,
}; };
} }
@@ -54,6 +55,11 @@ describe('openDb', () => {
expect(db.load('a')?.events).toHaveLength(1); expect(db.load('a')?.events).toHaveLength(1);
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
// the monotonic version is persisted with the state and round-trips
s.version = 3;
db.save(s);
expect(db.load('a')?.version).toBe(3);
db.close(); db.close();
}); });
+63 -1
View File
@@ -9,6 +9,7 @@ import {
isMeaningful, isMeaningful,
parseTurnResult, parseTurnResult,
queueRoundItem, queueRoundItem,
removeRoundItem,
runTurn, runTurn,
validateTurnResult, validateTurnResult,
} from './engine'; } from './engine';
@@ -72,6 +73,7 @@ function baseState(): GameState {
}, },
events: [], events: [],
pending: [], pending: [],
version: 1,
}; };
} }
@@ -206,8 +208,11 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(state.events[0].nation).toBe('uk'); expect(state.events[0].nation).toBe('uk');
expect(state.events[0].outcome).toBe('success'); expect(state.events[0].outcome).toBe('success');
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]); expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
// committing a turn is a new revision too
expect(state.version).toBe(2);
// the original object is not mutated (a fresh queue was consumed) // the original object is not mutated (a fresh queue was consumed)
expect(original.pending).toHaveLength(1); expect(original.pending).toHaveLength(1);
expect(original.version).toBe(1);
}); });
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => { it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
@@ -286,6 +291,61 @@ describe('queueRoundItem', () => {
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' }); expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
// the original state is not mutated // the original state is not mutated
expect(before.pending).toHaveLength(0); expect(before.pending).toHaveLength(0);
expect(before.version).toBe(1);
});
it('bumps the monotonic version on each queue mutation', () => {
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
expect(a.version).toBe(2);
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
expect(b.version).toBe(3);
// and never backwards: two consecutive adds from the same base both
// advance past it, so a late snapshot of the older state is always older.
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
expect(b2.version).toBe(2);
expect(b.version).toBeGreaterThan(b2.version);
});
it('treats a legacy state without a version field as version 0', () => {
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
delete legacy.version; // a row saved before the version field existed
// simulate a legacy row coming back from SQLite: the JSON has no field.
const after = queueRoundItem(legacy as unknown as GameState, {
kind: 'action',
text: 'first after upgrade',
});
expect(after.version).toBe(1);
});
});
describe('removeRoundItem', () => {
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'one' },
{ kind: 'diplomacy', to: 'us', text: 'two' },
{ kind: 'action', text: 'three' },
];
const next = removeRoundItem(state, 1);
expect(next).not.toBeNull();
expect(next!.version).toBe(2);
expect(next!.pending).toEqual([
{ kind: 'action', text: 'one' },
{ kind: 'action', text: 'three' },
]);
expect(state.pending).toHaveLength(3); // original untouched
expect(state.version).toBe(1);
});
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'one' }];
expect(removeRoundItem(state, -1)).toBeNull();
expect(removeRoundItem(state, 1)).toBeNull(); // == length
expect(removeRoundItem(state, 5)).toBeNull();
expect(removeRoundItem(state, 0.5)).toBeNull();
expect(removeRoundItem(state, Number.NaN)).toBeNull();
expect(state.pending).toHaveLength(1); // still untouched
}); });
}); });
@@ -346,7 +406,7 @@ describe('applyTurnResult', () => {
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
}); });
it('clears the pending round on commit', () => { it('clears the pending round on commit and bumps the version', () => {
const state = baseState(); const state = baseState();
state.pending = [{ kind: 'action', text: 'x' }]; state.pending = [{ kind: 'action', text: 'x' }];
const result: TurnResult = { const result: TurnResult = {
@@ -358,7 +418,9 @@ describe('applyTurnResult', () => {
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]); expect(next.pending).toEqual([]);
expect(next.version).toBe(2);
expect(state.pending).toHaveLength(1); // original untouched expect(state.pending).toHaveLength(1); // original untouched
expect(state.version).toBe(1);
}); });
}); });
+22 -2
View File
@@ -284,6 +284,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
*/ */
export function applyTurnResult(state: GameState, result: TurnResult): GameState { export function applyTurnResult(state: GameState, result: TurnResult): GameState {
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2); next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -325,15 +326,34 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
/** /**
* Immutably queue one item onto a round: append it to `pending` and return a new * Immutably queue one item onto a round: append it to `pending` and return a new
* state. This never advances the turn or the date — that only happens when a * state. This never advances the turn or the date — that only happens when a
* round is committed via runTurn. Exported so the append-without-advance behavior * round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
* is unit-testable, and reused by the server's /action and /diplomacy routes. * snapshots can never clobber it client-side). Exported so the append-without-
* advance behavior is unit-testable, and reused by the /action and /diplomacy
* routes.
*/ */
export function queueRoundItem(state: GameState, item: RoundItem): GameState { export function queueRoundItem(state: GameState, item: RoundItem): GameState {
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pending.push(item); next.pending.push(item);
return next; return next;
} }
/**
* Immutably remove one item from the pending round by index and return a new
* state (bumping `version`). Returns `null` when the index is missing or not a
* valid, in-bounds integer position, so the route can answer 400 without
* mutating anything. Reused by the server's /queue/remove route.
*/
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
return null;
}
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pending.splice(index, 1);
return next;
}
/** /**
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the * Run one turn end-to-end against the (injectable) LLM: build prompts, call the
* model ONCE over the whole pending batch, parse/validate, retry once with a * model ONCE over the whole pending batch, parse/validate, retry once with a
+24 -2
View File
@@ -9,11 +9,16 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol'; import { deepClone } from '../shared/protocol';
import type { AddActionRequest, AddDiplomacyRequest, CreateGameRequest } from '../shared/protocol'; import type {
AddActionRequest,
AddDiplomacyRequest,
CreateGameRequest,
RemoveQueueItemRequest,
} from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
import { createLlmClient } from './llm'; import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub'; import { makeStubLlmClient } from './stub';
import { askAdvisor, queueRoundItem, runTurn } from './engine'; import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { createPresetStore } from './presets'; import { createPresetStore } from './presets';
import { openDb, type Db } from './db'; import { openDb, type Db } from './db';
@@ -114,6 +119,7 @@ app.post('/api/games', (req: Request, res) => {
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])), nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [], events: [],
pending: [], pending: [],
version: 1, // the first saved revision; every later mutation bumps it
}; };
db.save(state); db.save(state);
res.json({ game: state }); res.json({ game: state });
@@ -179,6 +185,22 @@ app.post('/api/games/:id/diplomacy', (req, res) => {
res.json({ game: next }); res.json({ game: next });
}); });
// Remove one item from the pending round, by its index in `game.pending`. No
// LLM call, no turn advance — same fast save → broadcast → respond order as the
// add routes above. The mutated state bumps `version`, so clients (which apply
// states "never go backwards") always reconcile to it.
app.post('/api/games/:id/queue/remove', (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const index = (req.body as RemoveQueueItemRequest | undefined)?.index;
const next = removeRoundItem(game, index);
if (!next) return res.status(400).json({ error: 'invalid queue index' });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Jump forward: commit the whole pending round (or an empty one) to the model // Jump forward: commit the whole pending round (or an empty one) to the model
// in a single turn. applyTurnResult clears the pending queue on the returned // in a single turn. applyTurnResult clears the pending queue on the returned
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors. // state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
+1
View File
@@ -30,6 +30,7 @@ const state: GameState = {
}, },
events: [], events: [],
pending: [], pending: [],
version: 1,
}; };
describe('buildRegionMap', () => { describe('buildRegionMap', () => {
+8
View File
@@ -99,6 +99,14 @@ export interface TurnResponse {
result: TurnResult; result: TurnResult;
} }
/** Remove one queued item from the pending round, by its index in `game.pending`. */
export interface RemoveQueueItemRequest {
index: number;
}
export interface RemoveQueueItemResponse {
game: GameState;
}
export interface AdvisorRequest { export interface AdvisorRequest {
/** Optional prompt to steer the brainstorm. */ /** Optional prompt to steer the brainstorm. */
prompt?: string; prompt?: string;
+17
View File
@@ -85,6 +85,17 @@ export type RoundItem = {
to?: string; to?: string;
}; };
/**
* Monotonic revision counter for a game, bumped by the server on every
* mutation (queue add/remove, turn commit) and persisted with the state.
* Clients use it to tell a fresh state from a stale one: an SSE `state`/`turn`
* snapshot (e.g. the on-connect resync) that arrives *after* a fresher REST
* response carries a lower version and must be ignored, never applied
* (see the client's "never go backwards" rule in src/App.tsx). Legacy games
* saved before this field existed are treated as version 0 by consumers.
*/
export type GameVersion = number;
/** The full, persisted state of one game. Saved to SQLite by id. */ /** The full, persisted state of one game. Saved to SQLite by id. */
export interface GameState { export interface GameState {
id: string; id: string;
@@ -101,6 +112,12 @@ export interface GameState {
* batch in a single turn and clears this back to `[]`. * batch in a single turn and clears this back to `[]`.
*/ */
pending: RoundItem[]; pending: RoundItem[];
/**
* Monotonic revision counter (see GameVersion). Bumped on every mutation so
* clients can reject stale SSE snapshots; see the "never go backwards" rule
* in src/App.tsx. Starts at 1 on game creation.
*/
version: GameVersion;
} }
/** /**
+134 -70
View File
@@ -7,8 +7,6 @@ import {
ListTree, ListTree,
Loader2, Loader2,
Map as MapIcon, Map as MapIcon,
Minus,
Plus,
Radar, Radar,
} from 'lucide-react'; } from 'lucide-react';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
@@ -18,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { cn } from './lib/utils'; import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker'; import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect'; import NationSelect from './components/NationSelect';
import WorldMap, { type MapApi } from './components/WorldMap'; import WorldMap from './components/WorldMap';
import FloatingWindow from './components/FloatingWindow'; import FloatingWindow from './components/FloatingWindow';
import MapLegend from './components/MapLegend'; import MapLegend from './components/MapLegend';
import NationPanel from './components/NationPanel'; import NationPanel from './components/NationPanel';
@@ -46,13 +44,20 @@ function toastError(e: unknown): void {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Floating-window collapse persistence (positions live in FloatingWindow itself). // Floating-window collapse + dock persistence.
// (Free-floating *positions* live in FloatingWindow itself, per id.)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed'; const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock';
/** Every collapsible HUD panel (the slim header is never collapsed). */ /**
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const; * Every collapsible HUD panel (the slim, pinned header is never collapsed).
* The three substantive panels (dossier / feed / action) are also dockable;
* the legend stays a small floating widget, and there is no longer a "Map"
* window.
*/
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const;
function loadCollapsedMap(): Record<string, boolean> { function loadCollapsedMap(): Record<string, boolean> {
try { try {
@@ -78,6 +83,33 @@ function persistCollapsedMap(m: Record<string, boolean>): void {
} }
} }
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
type DockMode = 'left' | 'right' | 'float';
function loadDockMap(): Record<string, DockMode> {
try {
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
if (raw == null) return {};
const v: unknown = JSON.parse(raw);
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
const out: Record<string, DockMode> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
}
return out;
} catch {
return {};
}
}
function persistDockMap(m: Record<string, DockMode>): void {
try {
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
} catch {
/* storage unavailable — dock choices just won't persist */
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Active-game persistence. The id of the game the user is (or was last) playing // Active-game persistence. The id of the game the user is (or was last) playing
// is remembered in localStorage, so a page refresh drops them straight back // is remembered in localStorage, so a page refresh drops them straight back
@@ -153,6 +185,26 @@ export default function App() {
// never acts on a stale closure. Written each render (same pattern as sse.ts). // never acts on a stale closure. Written each render (same pattern as sse.ts).
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null); const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
// The single entry point for adopting a full game state — SSE `state`/`turn`
// events AND the REST responses (add/remove/jump) AND game loads all flow
// through here. Two rules:
// • a *different* game id (new game, deep link, resume) is always adopted;
// • the *same* game is adopted only when its `version` is not older than
// the current state's ("never go backwards").
// The comparison happens inside the functional updater, so it always reads
// the latest committed state — never a stale closure. This is what makes a
// late-arriving stale SSE snapshot (e.g. the on-connect resync taken just
// before an add was saved, or an EventSource auto-reconnect) harmless: it
// carries a lower version than the fresher state the REST response already
// applied, so it is dropped instead of clobbering the queued item (#10).
// Legacy games saved before `version` existed are treated as version 0.
const applyState = useCallback((s: GameState) => {
setGame((cur) => {
if (!cur || cur.id !== s.id) return s;
return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur;
});
}, []);
// On the home screen: load the scenario manifest and the list of saved games // On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game. // (newest-updated first) so the user can resume a prior game.
useEffect(() => { useEffect(() => {
@@ -192,7 +244,7 @@ export default function App() {
if (!alive) return; if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id); setActiveGameId(id);
setGame(game); applyState(game);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch { } catch {
if (!alive) return; if (!alive) return;
@@ -250,10 +302,9 @@ export default function App() {
}, [game]); }, [game]);
// Resync from the persisted server state on connect and after every turn. // Resync from the persisted server state on connect and after every turn.
// Handlers only call the stable setGame, so no stale-closure concerns.
useSse(game?.id ?? null, { useSse(game?.id ?? null, {
onState: (s) => setGame(s), onState: applyState,
onTurn: (s) => setGame(s), onTurn: applyState,
}); });
const play = useCallback(async (presetId: string) => { const play = useCallback(async (presetId: string) => {
@@ -282,7 +333,7 @@ export default function App() {
// Remember this as the active game so a refresh resumes it. // Remember this as the active game so a refresh resumes it.
persistActiveGameId(game.id); persistActiveGameId(game.id);
setActiveGameId(game.id); setActiveGameId(game.id);
setGame(game); applyState(game);
setAdvisors([]); setAdvisors([]);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch (e) { } catch (e) {
@@ -295,18 +346,20 @@ export default function App() {
); );
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending // Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
// list updates from the response and the SSE `state` event. // list updates from the response and the SSE `state` event. Both are
// version-gated via applyState, so the item can never be clobbered by a
// stale snapshot (#10).
const runAddAction = useCallback( const runAddAction = useCallback(
async (text: string) => { async (text: string) => {
if (!game) return; if (!game) return;
try { try {
const { game: g } = await api.addAction(game.id, text); const { game: g } = await api.addAction(game.id, text);
setGame(g); applyState(g);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} }
}, },
[game], [game, applyState],
); );
// Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`. // Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`.
@@ -315,12 +368,27 @@ export default function App() {
if (!game) return; if (!game) return;
try { try {
const { game: g } = await api.addDiplomacy(game.id, to, message); const { game: g } = await api.addDiplomacy(game.id, to, message);
setGame(g); applyState(g);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} }
}, },
[game], [game, applyState],
);
// Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list
// updates from the response and the SSE `state` event (version-gated).
const runRemoveQueueItem = useCallback(
async (index: number) => {
if (!game) return;
try {
const { game: g } = await api.removeQueueItem(game.id, index);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
); );
// Jump forward: the single LLM call that commits the pending round. Only this // Jump forward: the single LLM call that commits the pending round. Only this
@@ -330,13 +398,13 @@ export default function App() {
setBusy(true); setBusy(true);
try { try {
const { state } = await api.jump(game.id); const { state } = await api.jump(game.id);
setGame(state); applyState(state);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [game]); }, [game, applyState]);
const askAdvisor = useCallback( const askAdvisor = useCallback(
async (prompt?: string) => { async (prompt?: string) => {
@@ -362,7 +430,7 @@ export default function App() {
// Make this the active game so a bare `/` reload keeps resuming it. // Make this the active game so a bare `/` reload keeps resuming it.
persistActiveGameId(id); persistActiveGameId(id);
setActiveGameId(id); setActiveGameId(id);
setGame(game); applyState(game);
setAdvisors([]); setAdvisors([]);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch (e) { } catch (e) {
@@ -401,6 +469,7 @@ export default function App() {
advisors={advisors} advisors={advisors}
onAddAction={runAddAction} onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy} onAddDiplomacy={runAddDiplomacy}
onRemoveQueueItem={runRemoveQueueItem}
onAdvisor={askAdvisor} onAdvisor={askAdvisor}
onJump={runJump} onJump={runJump}
onHome={goHome} onHome={goHome}
@@ -440,6 +509,7 @@ interface GameScreenProps {
advisors: string[]; advisors: string[];
onAddAction: (a: string) => void; onAddAction: (a: string) => void;
onAddDiplomacy: (to: string, m: string) => void; onAddDiplomacy: (to: string, m: string) => void;
onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void; onAdvisor: (p?: string) => void;
onJump: () => void; onJump: () => void;
onHome: () => void; onHome: () => void;
@@ -447,14 +517,17 @@ interface GameScreenProps {
/** /**
* The main play view — a full-bleed world map with a floating HUD. The MapLibre * The main play view — a full-bleed world map with a floating HUD. The MapLibre
* map is a fixed, full-viewport background (z-0); over it float draggable, * map is a fixed, full-viewport background (z-0) that the user drives directly
* collapsible windows: a slim header bar (scenario, turn + date, player nation, * (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
* live status, "Focus map" collapse-all) at the top, the nation dossier * a slim **pinned** header bar (fixed, non-movable, centered at the top a few
* (top-left), the event feed (right), the "Your move" command deck * px down; scenario, turn + date, player nation, live status, "Focus map"
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom * collapse-all); the nation dossier, the event feed, and the "Your move"
* control (bottom-right). Each window remembers its dragged position, and * command deck — each free-floating by default (at the same first-run positions
* clicking one raises it above its siblings. Pan/zoom/click on the map stays * as before) but **dockable** into a full-height left or right pane, the choice
* fully available in the gaps between windows. * being remembered; and the nation-color legend, which stays a small floating
* widget (not dockable). Each floating window remembers its dragged position,
* and clicking one raises it above its siblings. Pan/zoom/click on the map
* stays fully available in the gaps between windows.
*/ */
function GameScreen({ function GameScreen({
game, game,
@@ -463,6 +536,7 @@ function GameScreen({
advisors, advisors,
onAddAction, onAddAction,
onAddDiplomacy, onAddDiplomacy,
onRemoveQueueItem,
onAdvisor, onAdvisor,
onJump, onJump,
onHome, onHome,
@@ -495,11 +569,23 @@ function GameScreen({
}); });
}, []); }, []);
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control. // Dock state for the three dockable panels (dossier / feed / action),
const mapApiRef = useRef<MapApi | null>(null); // persisted per id (survives reloads). The default is 'float' — the current
const handleMapReady = useCallback((api: MapApi | null) => { // first-run positions — so nothing visually changes until the user docks
mapApiRef.current = api; // something. The legend is not dockable; the header is pinned.
const [dock, setDock] = useState<Record<string, DockMode>>(loadDockMap);
const setDockFor = useCallback((id: string, mode: DockMode) => {
setDock((prev) => {
const next = { ...prev, [id]: mode };
persistDockMap(next);
return next;
});
}, []); }, []);
// The `dock` prop expects a concrete edge, or `undefined` to free-float.
const dockOf = (id: string) => {
const m = dock[id];
return m === 'left' || m === 'right' ? m : undefined;
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -510,13 +596,14 @@ function GameScreen({
nations={game.nations} nations={game.nations}
presetId={game.presetId} presetId={game.presetId}
onFocusNation={setViewingId} onFocusNation={setViewingId}
onMapReady={handleMapReady}
/> />
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */} {/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
Fixed and non-movable, centered at the top a few px from the top edge. */}
<FloatingWindow <FloatingWindow
id="header" id="header"
anchor="top-center" dataTestId="top-bar"
pinned
collapsible={false} collapsible={false}
title={ title={
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@@ -589,13 +676,16 @@ function GameScreen({
} }
/> />
{/* Nation dossier, top-left. */} {/* Nation dossier, top-left by default; dockable into a left/right pane. */}
<FloatingWindow <FloatingWindow
id="dossier" id="dossier"
dataTestId="panel-dossier"
anchor="top-left" anchor="top-left"
width={300} width={300}
title="Dossier" title="Dossier"
titleIcon={<BookOpen className="size-4" />} titleIcon={<BookOpen className="size-4" />}
dock={dockOf('dossier')}
onDockChange={(m) => setDockFor('dossier', m)}
collapsed={!!collapsed.dossier} collapsed={!!collapsed.dossier}
onCollapsedChange={(c) => setCollapsedFor('dossier', c)} onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
bodyClassName="max-h-[56vh] overflow-y-auto" bodyClassName="max-h-[56vh] overflow-y-auto"
@@ -620,9 +710,10 @@ function GameScreen({
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow /> <NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
</FloatingWindow> </FloatingWindow>
{/* Event feed, right side. */} {/* Event feed, right side by default; dockable into a left/right pane. */}
<FloatingWindow <FloatingWindow
id="feed" id="feed"
dataTestId="panel-feed"
anchor="right" anchor="right"
width={330} width={330}
title="Event feed" title="Event feed"
@@ -632,6 +723,8 @@ function GameScreen({
{game.events.length} {game.events.length}
</Badge> </Badge>
} }
dock={dockOf('feed')}
onDockChange={(m) => setDockFor('feed', m)}
collapsed={!!collapsed.feed} collapsed={!!collapsed.feed}
onCollapsedChange={(c) => setCollapsedFor('feed', c)} onCollapsedChange={(c) => setCollapsedFor('feed', c)}
bodyClassName="h-[52vh]" bodyClassName="h-[52vh]"
@@ -639,9 +732,10 @@ function GameScreen({
<EventFeed events={game.events} nations={game.nations} inWindow /> <EventFeed events={game.events} nations={game.nations} inWindow />
</FloatingWindow> </FloatingWindow>
{/* Command deck ("Your move"), bottom-center — queue items, then commit. */} {/* Command deck ("Your move"), bottom-center by default; dockable. */}
<FloatingWindow <FloatingWindow
id="action" id="action"
dataTestId="panel-action"
anchor="bottom-center" anchor="bottom-center"
width={370} width={370}
title="Your move" title="Your move"
@@ -653,6 +747,8 @@ function GameScreen({
</Badge> </Badge>
) : undefined ) : undefined
} }
dock={dockOf('action')}
onDockChange={(m) => setDockFor('action', m)}
collapsed={!!collapsed.action} collapsed={!!collapsed.action}
onCollapsedChange={(c) => setCollapsedFor('action', c)} onCollapsedChange={(c) => setCollapsedFor('action', c)}
bodyClassName="max-h-[72vh] overflow-y-auto" bodyClassName="max-h-[72vh] overflow-y-auto"
@@ -663,6 +759,7 @@ function GameScreen({
advisors={advisors} advisors={advisors}
onAddAction={onAddAction} onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy} onAddDiplomacy={onAddDiplomacy}
onRemoveQueueItem={onRemoveQueueItem}
onAdvisor={onAdvisor} onAdvisor={onAdvisor}
onJump={onJump} onJump={onJump}
inWindow inWindow
@@ -681,39 +778,6 @@ function GameScreen({
> >
<MapLegend regions={regions} /> <MapLegend regions={regions} />
</FloatingWindow> </FloatingWindow>
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
<FloatingWindow
id="zoom"
anchor="bottom-right"
width={88}
title="Map"
titleIcon={<MapIcon className="size-4" />}
offset={{ y: -28 }}
collapsed={!!collapsed.zoom}
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
>
<div className="flex items-center justify-center gap-1.5 p-2.5">
<Button
variant="secondary"
size="sm"
onClick={() => mapApiRef.current?.zoomIn()}
aria-label="Zoom in"
data-testid="zoom-in"
>
<Plus className="size-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => mapApiRef.current?.zoomOut()}
aria-label="Zoom out"
data-testid="zoom-out"
>
<Minus className="size-4" />
</Button>
</div>
</FloatingWindow>
</div> </div>
); );
} }
+76 -5
View File
@@ -1,13 +1,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loader2, MessageSquare, Radar, Zap } from 'lucide-react'; import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Textarea } from './ui/textarea'; import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import type { GameState } from '@shared/types'; import type { GameState, RoundItem } from '@shared/types';
interface Props { interface Props {
game: GameState; game: GameState;
@@ -19,6 +27,8 @@ interface Props {
onAddAction: (text: string) => void; onAddAction: (text: string) => void;
/** Queue a diplomatic message onto the pending round (no turn advance). */ /** Queue a diplomatic message onto the pending round (no turn advance). */
onAddDiplomacy: (to: string, message: string) => void; onAddDiplomacy: (to: string, message: string) => void;
/** Remove one queued item by its index in `game.pending` (no turn advance). */
onRemoveQueueItem: (index: number) => void;
onAdvisor: (prompt?: string) => void; onAdvisor: (prompt?: string) => void;
/** Commit the whole pending round in a single LLM turn. */ /** Commit the whole pending round in a single LLM turn. */
onJump: () => void; onJump: () => void;
@@ -29,13 +39,22 @@ interface Props {
inWindow?: boolean; inWindow?: boolean;
} }
/** One-line, display-safe rendering of a queued item (text truncated). */
function describeItem(item: RoundItem, nationName: (id?: string) => string): string {
const who = item.kind === 'diplomacy' ? ` to ${nationName(item.to)}` : '';
const text = item.text.length > 72 ? `${item.text.slice(0, 71)}…` : item.text;
return `${who ? `${who}:` : 'Action:'} “${text}”`;
}
/** /**
* The player's command deck ("Your move"). Three tabs — Action, Diplomacy, * The player's command deck ("Your move"). Three tabs — Action, Diplomacy,
* Advisor — each with a small "Add" button that queues an item onto the pending * Advisor — each with a small "Add" button that queues an item onto the pending
* round (these never advance the turn). Below the tabs a visible pending-queue * round (these never advance the turn). Below the tabs a visible pending-queue
* list and one prominent "Jump forward" button commit the whole round in a * list — each row has a small trash button that removes that item (with a
* single LLM turn. While a jump is in flight the commit is disabled and a hint * confirmation dialog) — and one prominent "Jump forward" button that commits
* is shown; an empty jump (nothing queued) is allowed and advances time. * the whole round in a single LLM turn. While a jump is in flight the commit
* is disabled (as is queue editing) and a hint is shown; an empty jump
* (nothing queued) is allowed and advances time.
*/ */
export default function ActionPanel({ export default function ActionPanel({
game, game,
@@ -43,6 +62,7 @@ export default function ActionPanel({
advisors, advisors,
onAddAction, onAddAction,
onAddDiplomacy, onAddDiplomacy,
onRemoveQueueItem,
onAdvisor, onAdvisor,
onJump, onJump,
inWindow = false, inWindow = false,
@@ -52,6 +72,8 @@ export default function ActionPanel({
const [target, setTarget] = useState(''); const [target, setTarget] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
// Index of the queued item the user is about to remove, or null (dialog closed).
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
const others = Object.entries(game.nations) const others = Object.entries(game.nations)
.filter(([id]) => id !== game.playerNationId) .filter(([id]) => id !== game.playerNationId)
@@ -79,6 +101,10 @@ export default function ActionPanel({
}; };
const pending = game.pending; const pending = game.pending;
// The item the confirmation dialog is about (re-derived from the live list,
// so if the queue changes while the dialog is open it tracks the current
// item at that index — or the dialog closes if the index went out of range).
const removeTarget = confirmRemoveIndex !== null ? pending[confirmRemoveIndex] : undefined;
const queuedLabel = busy const queuedLabel = busy
? 'Simulating…' ? 'Simulating…'
: `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`; : `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`;
@@ -227,6 +253,18 @@ export default function ActionPanel({
</span> </span>
)} )}
<span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span> <span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setConfirmRemoveIndex(i)}
disabled={busy}
title="Remove from this round"
aria-label="Remove this queued item"
data-testid="remove-queue-item"
>
<Trash2 className="size-3.5" />
</Button>
</li> </li>
))} ))}
</ul> </ul>
@@ -234,6 +272,39 @@ export default function ActionPanel({
)} )}
</div> </div>
{/* Confirmation for removing a queued item (never removes without asking). */}
<Dialog open={removeTarget !== undefined} onOpenChange={(open) => !open && setConfirmRemoveIndex(null)}>
<DialogContent className="max-w-sm" data-testid="remove-confirm-dialog">
<DialogHeader>
<DialogTitle>Remove queued item?</DialogTitle>
<DialogDescription>
{removeTarget ? (
<>
{describeItem(removeTarget, nationName)} will be removed from this round and
won’t be simulated on the next jump.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmRemoveIndex(null)} data-testid="remove-cancel">
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (confirmRemoveIndex !== null) onRemoveQueueItem(confirmRemoveIndex);
setConfirmRemoveIndex(null);
}}
data-testid="remove-confirm"
>
<Trash2 className="size-4" />
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward"> <Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />} {busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
{queuedLabel} {queuedLabel}
+154 -32
View File
@@ -6,7 +6,7 @@ import {
type CSSProperties, type CSSProperties,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { Minus, Plus } from 'lucide-react'; import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Edge anchor used to place a window on first run (before the user drags it). */ /** Edge anchor used to place a window on first run (before the user drags it). */
@@ -19,6 +19,11 @@ export type FloatAnchor =
| 'bottom-center' | 'bottom-center'
| 'bottom-right'; | 'bottom-right';
/** A docked column edge: pinned to a screen edge and filling the viewport height. */
export type FloatDock = 'left' | 'right';
/** A dock mode: a docked edge, or `'float'` (free-floating, the default). */
export type FloatDockMode = FloatDock | 'float';
const STORAGE_PREFIX = 'history.floatwin.v1.'; const STORAGE_PREFIX = 'history.floatwin.v1.';
// Module-level z-index bookkeeping so sibling windows stack in click order: // Module-level z-index bookkeeping so sibling windows stack in click order:
@@ -26,6 +31,9 @@ const STORAGE_PREFIX = 'history.floatwin.v1.';
// raised, the shared counter advances, so the newest on top always wins. // raised, the shared counter advances, so the newest on top always wins.
const Z_BASE = 10; const Z_BASE = 10;
const Z_STEP = 10; const Z_STEP = 10;
// A pinned window always floats above every docked/floating window, no matter
// how many times those are raised.
const Z_PINNED = 100000;
let zSeq = 0; let zSeq = 0;
let zTopId: string | null = null; let zTopId: string | null = null;
/** Returns the new z-index to use, or -1 if this window is already front-most. */ /** Returns the new z-index to use, or -1 if this window is already front-most. */
@@ -118,8 +126,31 @@ function clampPos(x: number, y: number, w: number, h: number): Pos {
export interface FloatingWindowProps { export interface FloatingWindowProps {
/** Stable id — used for the localStorage key and as a data-attribute. */ /** Stable id — used for the localStorage key and as a data-attribute. */
id: string; id: string;
/** First-run placement. The last dragged position (persisted) takes over. */ /** Optional `data-testid` on the window root, for e2e targeting. */
anchor: FloatAnchor; dataTestId?: string;
/**
* First-run placement for a free-floating window; the last dragged position
* (persisted) takes over. Ignored when `pinned` or `dock` is set.
*/
anchor?: FloatAnchor;
/**
* Pin the window to the top-center of the viewport: a fixed, non-movable bar
* a few px from the top that re-centers automatically on resize, always
* floats above docked/floating panels, and never reads/writes a persisted
* position. Used for the slim header.
*/
pinned?: boolean;
/**
* Dock the window into a full-height left or right column: non-movable,
* body scrolls, never reads/writes the persisted free-floating position.
* Absent (`undefined`) = free-floating as before.
*/
dock?: FloatDock;
/**
* When provided, render dock controls (left / right / float) in the title bar
* and report the chosen mode back to the parent, which persists it.
*/
onDockChange?: (mode: FloatDockMode) => void;
/** Fixed window width in px (content is sized to it). Omit to fit content. */ /** Fixed window width in px (content is sized to it). Omit to fit content. */
width?: number; width?: number;
/** Extra px nudged away from the anchor position (e.g. clear the map attribution). */ /** Extra px nudged away from the anchor position (e.g. clear the map attribution). */
@@ -128,7 +159,7 @@ export interface FloatingWindowProps {
title?: ReactNode; title?: ReactNode;
/** Optional icon before the title. */ /** Optional icon before the title. */
titleIcon?: ReactNode; titleIcon?: ReactNode;
/** Right-side title-bar content, rendered before the collapse button. */ /** Right-side title-bar content, rendered before the dock/collapse controls. */
titleExtra?: ReactNode; titleExtra?: ReactNode;
/** Render the collapse toggle (default true). */ /** Render the collapse toggle (default true). */
collapsible?: boolean; collapsible?: boolean;
@@ -143,16 +174,27 @@ export interface FloatingWindowProps {
} }
/** /**
* A draggable, collapsible, position-persisted window that floats over the * A window that floats over the full-screen map. Three mutually-exclusive
* full-screen map. Dragging is limited to the title bar (via pointer capture) * modes, set via props (absent = free-floating):
* so inputs and buttons in the body keep working; the window is clamped so it * • **free-floating** (default) — draggable by the title bar (pointer
* can never be dragged fully off-screen, its position is remembered in * capture), clamped on-screen, position persisted in localStorage, raised
* localStorage, clicking any part of it raises it above siblings, and the * above siblings on click, collapsible.
* collapse toggle shrinks it to just its title bar. * • **pinned** — a fixed, non-movable bar centered at the top of the
* viewport a few px down; re-centers on resize; always above other
* windows; no position persisted.
* • **docked** (`left`/`right`) — a non-movable, full-height column pinned
* to a screen edge whose body scrolls; the persisted free-floating
* position is neither read nor written while docked.
* When `onDockChange` is provided, small dock/left/right/float controls render
* in the title bar and report the chosen mode back to the parent.
*/ */
export default function FloatingWindow({ export default function FloatingWindow({
id, id,
anchor, dataTestId,
anchor = 'top-center',
pinned = false,
dock,
onDockChange,
width, width,
offset, offset,
title, title,
@@ -165,6 +207,12 @@ export default function FloatingWindow({
bodyClassName, bodyClassName,
children, children,
}: FloatingWindowProps) { }: FloatingWindowProps) {
const isPinned = pinned;
const isDocked = dock === 'left' || dock === 'right';
// Free-floating is the only mode that drags, persists a position, and needs
// viewport measurement/clamping. Pinned and docked are CSS-positioned.
const isFloating = !isPinned && !isDocked;
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const sizeRef = useRef<{ w: number; h: number } | null>(null); const sizeRef = useRef<{ w: number; h: number } | null>(null);
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null); const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
@@ -177,9 +225,11 @@ export default function FloatingWindow({
setPosState(p); setPosState(p);
}; };
// First paint: measure, then place (restored position or anchor) and clamp. // First paint (free-floating only): measure, then place (restored position
// Runs before paint (useLayoutEffect), so there is no visible flash. // or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash.
// Re-runs when the mode changes so a window returning from docked re-places.
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isFloating) return;
if (posRef.current) return; if (posRef.current) return;
const el = rootRef.current; const el = rootRef.current;
if (!el) return; if (!el) return;
@@ -195,11 +245,12 @@ export default function FloatingWindow({
); );
posRef.current = p; posRef.current = p;
setPosState(p); setPosState(p);
}, [id, anchor]); }, [id, anchor, offset, isFloating]);
// Re-measure + re-clamp whenever the body toggles hidden (expand can push a // Re-measure + re-clamp whenever the body toggles hidden (expand can push a
// bottom-anchored window off the bottom edge). // bottom-anchored window off the bottom edge). Free-floating only.
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isFloating) return;
const el = rootRef.current; const el = rootRef.current;
const p = posRef.current; const p = posRef.current;
if (!el || !p) return; if (!el || !p) return;
@@ -208,10 +259,11 @@ export default function FloatingWindow({
sizeRef.current = { w: rect.width, h: rect.height }; sizeRef.current = { w: rect.width, h: rect.height };
const c = clampPos(p.x, p.y, rect.width, rect.height); const c = clampPos(p.x, p.y, rect.width, rect.height);
if (c.x !== p.x || c.y !== p.y) setPos(c); if (c.x !== p.x || c.y !== p.y) setPos(c);
}, [collapsed]); }, [collapsed, isFloating]);
// Keep the window on-screen when the viewport shrinks. // Keep a free-floating window on-screen when the viewport shrinks.
useEffect(() => { useEffect(() => {
if (!isFloating) return;
const onResize = () => { const onResize = () => {
const p = posRef.current; const p = posRef.current;
const s = sizeRef.current; const s = sizeRef.current;
@@ -221,7 +273,7 @@ export default function FloatingWindow({
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, []); }, [isFloating]);
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => { const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; if (e.button !== 0) return;
@@ -258,18 +310,44 @@ export default function FloatingWindow({
if (p) savePos(id, p.x, p.y); if (p) savePos(id, p.x, p.y);
}; };
const style: CSSProperties = { // Root placement: pinned → centered top bar; docked → full-height edge
// column; free-floating → last/initial pixel position (hidden until measured).
const rootStyle: CSSProperties = isPinned
? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
: isDocked
? {
zIndex: z, zIndex: z,
...(pos ? { left: pos.x, top: pos.y } : {}), top: 0,
}; height: '100vh',
...(dock === 'left' ? { left: 0 } : { right: 0 }),
}
: { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) };
// The title bar is the drag handle only in free-floating mode.
const titleBarHandlers = isFloating
? {
onPointerDown: onHandleDown,
onPointerMove: onHandleMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
}
: {};
// Docked: the body fills the remaining column height and scrolls, overriding
// any max-height the caller set for the floating layout.
const bodyClass = isDocked
? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden')
: cn('min-h-0', collapsed && 'hidden', bodyClassName);
return ( return (
<div <div
ref={rootRef} ref={rootRef}
data-float-window={id} data-float-window={id}
style={style} data-testid={dataTestId}
className={cn('fixed', !pos && 'pointer-events-none opacity-0')} style={rootStyle}
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
onPointerDown={() => { onPointerDown={() => {
if (isPinned) return;
const nz = nextZ(id); const nz = nextZ(id);
if (nz > 0) setZ(nz); if (nz > 0) setZ(nz);
}} }}
@@ -277,21 +355,24 @@ export default function FloatingWindow({
<div <div
className={cn( className={cn(
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md', 'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
isDocked && 'flex h-full flex-col',
className, className,
)} )}
style={ style={
width != null isDocked
? { width: width ?? 'fit-content', maxWidth: '96vw' }
: width != null
? { width, maxWidth: '96vw' } ? { width, maxWidth: '96vw' }
: { width: 'fit-content', maxWidth: '96vw' } : { width: 'fit-content', maxWidth: '96vw' }
} }
> >
{title != null && ( {title != null && (
<div <div
className="flex cursor-grab touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5 active:cursor-grabbing" className={cn(
onPointerDown={onHandleDown} 'flex shrink-0 touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5',
onPointerMove={onHandleMove} isFloating && 'cursor-grab active:cursor-grabbing',
onPointerUp={endDrag} )}
onPointerCancel={endDrag} {...titleBarHandlers}
> >
{titleIcon && ( {titleIcon && (
<span className="shrink-0 text-muted-foreground" aria-hidden> <span className="shrink-0 text-muted-foreground" aria-hidden>
@@ -302,6 +383,49 @@ export default function FloatingWindow({
{title} {title}
</span> </span>
{titleExtra} {titleExtra}
{onDockChange && (
<span className="flex shrink-0 items-center gap-0.5" data-no-drag>
<button
type="button"
data-testid="dock-left"
aria-label="Dock to the left edge"
title="Dock to the left edge"
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
dock === 'left' && 'bg-white/10 text-foreground',
)}
onClick={() => onDockChange('left')}
>
<AlignLeft className="size-3.5" />
</button>
<button
type="button"
data-testid="dock-right"
aria-label="Dock to the right edge"
title="Dock to the right edge"
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
dock === 'right' && 'bg-white/10 text-foreground',
)}
onClick={() => onDockChange('right')}
>
<AlignRight className="size-3.5" />
</button>
<button
type="button"
data-testid="dock-float"
aria-label="Float the window"
title="Float (undock)"
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
!isDocked && 'bg-white/10 text-foreground',
)}
onClick={() => onDockChange('float')}
>
<Maximize2 className="size-3.5" />
</button>
</span>
)}
{collapsibleProp && ( {collapsibleProp && (
<button <button
type="button" type="button"
@@ -315,9 +439,7 @@ export default function FloatingWindow({
)} )}
</div> </div>
)} )}
{children != null && ( {children != null && <div className={bodyClass}>{children}</div>}
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
)}
</div> </div>
</div> </div>
); );
+2 -29
View File
@@ -170,13 +170,6 @@ function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation):
</div>`; </div>`;
} }
/** Minimal map API exposed to the floating zoom control (and any other UI). */
export interface MapApi {
zoomIn: () => void;
zoomOut: () => void;
reset: () => void;
}
interface Props { interface Props {
regions: RegionMap; regions: RegionMap;
playerNationId: string; playerNationId: string;
@@ -187,11 +180,6 @@ interface Props {
className?: string; className?: string;
/** Called when a region is clicked, so the dossier can focus its owner. */ /** Called when a region is clicked, so the dossier can focus its owner. */
onFocusNation?: (id: string) => void; onFocusNation?: (id: string) => void;
/**
* Called with a minimal zoom/pan API once the map is ready (and with `null`
* on unmount), so the floating zoom control can drive it.
*/
onMapReady?: (api: MapApi | null) => void;
} }
/** /**
@@ -201,8 +189,8 @@ interface Props {
* stable fills (the choropleth is the star), thin borders, a brighter/thicker * stable fills (the choropleth is the star), thin borders, a brighter/thicker
* border for the player, hover + click popups with * border for the player, hover + click popups with
* owner + stats, a transient flash on territory changes, and a * owner + stats, a transient flash on territory changes, and a
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating * per-scenario initial camera. Zoom and pan are driven directly by the user
* zoom control can drive it (no MapLibre navigation controls are shown). * (MapLibre wheel/trackpad + drag) — no floating control and no external API.
*/ */
export default function WorldMap({ export default function WorldMap({
regions, regions,
@@ -211,7 +199,6 @@ export default function WorldMap({
presetId, presetId,
className, className,
onFocusNation, onFocusNation,
onMapReady,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
@@ -222,13 +209,11 @@ export default function WorldMap({
const playerRef = useRef(playerNationId); const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId); const presetRef = useRef(presetId);
const onFocusNationRef = useRef(onFocusNation); const onFocusNationRef = useRef(onFocusNation);
const onMapReadyRef = useRef(onMapReady);
regionsRef.current = regions; regionsRef.current = regions;
nationsRef.current = nations; nationsRef.current = nations;
playerRef.current = playerNationId; playerRef.current = playerNationId;
presetRef.current = presetId; presetRef.current = presetId;
onFocusNationRef.current = onFocusNation; onFocusNationRef.current = onFocusNation;
onMapReadyRef.current = onMapReady;
// Diffing state for the transient territory-change flash (owned by this component // Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes). // so a resync with identical data never re-flashes).
@@ -257,17 +242,6 @@ export default function WorldMap({
// Purely for testability; has no effect on rendering or behavior. // Purely for testability; has no effect on rendering or behavior.
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map; (containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map;
// Expose a tiny zoom/pan API to the floating control (null on unmount).
const api: MapApi = {
zoomIn: () => map.zoomIn(),
zoomOut: () => map.zoomOut(),
reset: () => {
const c = cameraForPreset(presetRef.current);
map.easeTo({ center: c.center, zoom: c.zoom, duration: 600 });
},
};
onMapReadyRef.current?.(api);
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 }); const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
let lastHoverId: string | null = null; let lastHoverId: string | null = null;
@@ -482,7 +456,6 @@ export default function WorldMap({
cancelled = true; cancelled = true;
cancelAnimationFrame(flashRaf); cancelAnimationFrame(flashRaf);
flashApiRef.current = null; flashApiRef.current = null;
onMapReadyRef.current?.(null);
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
if (containerRef.current) { if (containerRef.current) {
+8
View File
@@ -8,6 +8,7 @@ import type {
AdvisorResponse, AdvisorResponse,
CreateGameResponse, CreateGameResponse,
GameSummary, GameSummary,
RemoveQueueItemResponse,
TurnResponse, TurnResponse,
} from '@shared/protocol'; } from '@shared/protocol';
@@ -48,6 +49,13 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ to, message }), body: JSON.stringify({ to, message }),
}), }),
// Remove one queued item from the pending round, by its index in `game.pending`.
// Fast, non-LLM — no turn advance. Returns the updated game.
removeQueueItem: (id: string, index: number) =>
req<RemoveQueueItemResponse>(`/api/games/${id}/queue/remove`, {
method: 'POST',
body: JSON.stringify({ index }),
}),
// Commit the whole pending round (or an empty one) in a single LLM turn. // Commit the whole pending round (or an empty one) in a single LLM turn.
jump: (id: string) => jump: (id: string) =>
req<TurnResponse>(`/api/games/${id}/jump`, { req<TurnResponse>(`/api/games/${id}/jump`, {