Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b167cb435 | ||
|
|
762c120bbd | ||
|
|
cfcdd8b8a8 | ||
|
|
ef27b59762 | ||
|
|
3287bcaca4 |
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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).",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const state: GameState = {
|
|||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('buildRegionMap', () => {
|
describe('buildRegionMap', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
zIndex: z,
|
// column; free-floating → last/initial pixel position (hidden until measured).
|
||||||
...(pos ? { left: pos.x, top: pos.y } : {}),
|
const rootStyle: CSSProperties = isPinned
|
||||||
};
|
? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
|
||||||
|
: isDocked
|
||||||
|
? {
|
||||||
|
zIndex: z,
|
||||||
|
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, maxWidth: '96vw' }
|
? { width: width ?? 'fit-content', maxWidth: '96vw' }
|
||||||
: { width: 'fit-content', maxWidth: '96vw' }
|
: width != null
|
||||||
|
? { width, 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user