Compare commits
13
Commits
queue-add-fix
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8252b665b5 | ||
|
|
7d50cba061 | ||
|
|
e70f65f112 | ||
|
|
e38cc5933f | ||
|
|
858b901b8d | ||
|
|
17d6cecab9 | ||
|
|
f05ddb304d | ||
|
|
eadae4dc4f | ||
|
|
67f48fb99c | ||
|
|
c19db2c118 | ||
|
|
977bf584ce | ||
|
|
6b167cb435 | ||
|
|
762c120bbd |
@@ -72,10 +72,11 @@ history/
|
|||||||
│ └── utils.ts # cn()
|
│ └── utils.ts # cn()
|
||||||
└── components/
|
└── components/
|
||||||
├── 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→dossier + hover tooltip + 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 (per-item trash button + confirm dialog) + "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
|
||||||
```
|
```
|
||||||
@@ -119,6 +120,10 @@ history/
|
|||||||
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
|
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.
|
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
||||||
|
- **Advisor "thinking" state (separate from the turn `busy`).** The Advisor tab shows a thinking hint
|
||||||
|
(mirroring the turn's `sim-hint`) and disables its "Ask advisor" button while the advisor LLM call is
|
||||||
|
in flight, and the top-bar `status-indicator` reads "Advisor thinking…". It is a distinct `advisorBusy`
|
||||||
|
flag in `App.tsx` and must never gate "Jump forward" or the turn flow.
|
||||||
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
||||||
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
||||||
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
||||||
@@ -135,6 +140,17 @@ history/
|
|||||||
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
|
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
|
||||||
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
|
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
|
||||||
server's `RegionMap` and any client coloring so the choropleth always agrees.
|
server's `RegionMap` and any client coloring so the choropleth always agrees.
|
||||||
|
- **Map click → dossier target (owner resolved from the RegionMap).** Clicking a map region does
|
||||||
|
NOT open a popup — it switches the dossier to that region's owning nation
|
||||||
|
(`setViewingId(ownerId)` in `GameScreen`); the manual "Inspect" selector and a map click both set
|
||||||
|
the same target and stay in sync. The owning nation is resolved from the authoritative `RegionMap`
|
||||||
|
by the feature's stable ISO_A3 code, which `applyRegions` stamps into `properties.regionId`
|
||||||
|
(properties survive into the rendered features) and the shared `regionOwner()` helper looks up.
|
||||||
|
It must **never** be read from the feature's top-level `id`: MapLibre repacks a GeoJSON source's
|
||||||
|
`id` into a numeric index when it stores the source, so at click/hover time `feature.id` is a
|
||||||
|
number, never the ISO_A3 (that repacking was the "no owner" bug, #6). Unowned regions (no
|
||||||
|
`RegionMap` entry) leave the current dossier target unchanged — never a broken dossier. The
|
||||||
|
hover path (a non-closeable tooltip with owner + key stats) uses the same resolution.
|
||||||
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
||||||
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
||||||
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||||||
@@ -142,6 +158,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`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.10.0 — 2026-10-11 21:25
|
||||||
|
- **Fix world map style (#2)** — made the choropleth fill fully opaque (`fill-opacity` 0.85 → 1.0) and darkened the Natural Earth terrain raster (`raster-opacity` 0.5 → 0.15), so the raster's flat water tint and fine 1:10m coastline no longer bleed through countries and compete with the borders.
|
||||||
|
- **Strengthened country border lines** — border width 0.7 → 1.25, and the per-region stroke alphas raised (owned 0.10 → 0.30, unowned 0.06 → 0.14) so the vector borders read clearly as the dominant edge, with owned regions still more prominent than unowned ones.
|
||||||
|
- Fixes #2.
|
||||||
|
|
||||||
|
## v0.9.0 — 2026-10-11 20:57
|
||||||
|
- **Reduced turn latency (prompt-level):** capped `recentEvents` in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (`reasoning_effort: low`); the deterministic stub, the `TurnResult` contract, `max_tokens`, and `reasoning_effort` are all unchanged.
|
||||||
|
|
||||||
|
## v0.8.0 — 2026-10-11 20:29
|
||||||
|
|
||||||
|
- **Remove a saved game (#20)** — every saved game now has a delete control: a trash button on its card in the home screen's "Saved games" section opens a confirmation dialog ("Delete this game?" showing the scenario/turn/date) with Cancel and a destructive Delete. The new `DELETE /api/games/:id` route removes the game's row from SQLite and returns `{ deleted: true }` (404 if the id is unknown).
|
||||||
|
- **Redirect on a missing/deleted game (#20)** — loading a non-existent game id no longer leaves you stranded. A deep link to a missing game already fell back to home, and now a game that disappears *while its tab is open* (deleted from another tab or the same session) is pushed home too: the server broadcasts an SSE `error` event on delete, and the client's `useSse` gains an `onError` handler that returns the tab to the home screen (matching the existing "game not found" connect behavior).
|
||||||
|
- `PresetPicker` gained an `onDelete` prop, a per-card `data-game-id` attribute, the delete button, and the confirm dialog; `api.deleteGame(id)`, `Db.delete(id)`, and the shared `DeleteGameResponse` type were added to support it.
|
||||||
|
- **Unit + e2e tests** — new `Db.delete` test (removes by id; reports absence for unknown / already-gone ids) and two e2e specs: (a) deleting a saved game from the home screen (open → cancel leaves it; confirm removes the card, the response is `{deleted:true}`, and a follow-up `GET` is 404), and (b) deleting an open game (the live tab is pushed home via the SSE error, and a fresh deep-link to the deleted id lands on home).
|
||||||
|
|
||||||
|
## v0.7.0 — 2026-10-11 19:17
|
||||||
|
|
||||||
|
- Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target.
|
||||||
|
- Fixed: a clicked owned region no longer reports "no owner" (#6). Root cause: MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3 string) into a numeric index when it stores the source, so the feature a click/hover handler receives has a *number* for `id` — the old `regions[f.id]` lookup resolved to nothing, and the popup read "No owner" even for owned regions (the choropleth fill still worked because it is stamped from the original `id` before `setData`). Fix: `applyRegions` now stamps the stable ISO_A3 code into `properties.regionId` (and `ownerId`), which survives into the rendered features, and both the click and hover handlers resolve the owning nation from that code via the authoritative RegionMap — never from the repacked `feature.id`.
|
||||||
|
- Unowned regions (no entry in the RegionMap) do not change the dossier when clicked — the current target is left in place (a deliberate, non-crashing choice rather than a special "unowned" dossier state).
|
||||||
|
- New shared helper `regionOwner(regions, isoA3)` in `@shared/protocol` (the pure, single source of truth for "ISO_A3 → owner record").
|
||||||
|
- **Unit tests** — new `regionOwner` tests (owned → owner record, unowned → undefined, empty/missing → undefined) plus a #6 regression guard that pins the exact MapLibre-repacked feature shape (numeric `id`, ISO_A3 in `properties.regionId`) and asserts resolution via `properties.regionId` works while the old `feature.id` path fails (44 tests, up from 39).
|
||||||
|
|
||||||
|
## v0.6.0 — 2026-10-11 18:27
|
||||||
|
|
||||||
|
- Advisor fetches now show a "thinking" indicator (mirroring the turn's "Simulating…" hint) while the model responds, and the Ask-advisor button disables during the fetch; the top-bar status shows "Advisor thinking…" in the meantime. Fixes #15.
|
||||||
|
|
||||||
|
## 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
|
## 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).
|
- **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).
|
||||||
|
|||||||
+224
@@ -419,3 +419,227 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advisor "thinking" indicator (issue #15). Asking the advisor is a *separate*
|
||||||
|
* LLM call with its own `advisorBusy` flag — it must never block the turn flow.
|
||||||
|
* Under the deterministic stub the fetch resolves in milliseconds, so the
|
||||||
|
* transient thinking state is too short to assert mid-flight reliably. Instead
|
||||||
|
* we assert the *terminal* states, which are deterministic: before the fetch the
|
||||||
|
* button is enabled and the hint is absent; after it, the advisor list is
|
||||||
|
* populated (5 canned ideas), the hint is gone, the button is re-enabled, and
|
||||||
|
* the top bar is back to "Idle".
|
||||||
|
*/
|
||||||
|
test.describe('History — Advisor thinking indicator (#15)', () => {
|
||||||
|
test('asking the advisor populates the list and clears the busy state', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
|
||||||
|
// Open the Advisor tab.
|
||||||
|
await page.getByTestId('tab-advisor').click();
|
||||||
|
|
||||||
|
// Before any fetch: the button is enabled, no thinking hint, no results yet.
|
||||||
|
const ask = page.getByTestId('ask-advisor');
|
||||||
|
await expect(ask).toBeEnabled();
|
||||||
|
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('advisor-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Ask the advisor (with an optional focus). The stub returns 5 canned ideas.
|
||||||
|
await page.getByTestId('advisor-prompt').fill('the nuclear standoff');
|
||||||
|
await ask.click();
|
||||||
|
|
||||||
|
// Terminal state: the result populates the advisor list.
|
||||||
|
await expect(page.getByTestId('advisor-list')).toBeVisible({ timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('advisor-item')).toHaveCount(5, { timeout: 20000 });
|
||||||
|
|
||||||
|
// The fetch finished: the thinking hint is gone and the button is re-enabled.
|
||||||
|
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||||
|
await expect(ask).toBeEnabled();
|
||||||
|
|
||||||
|
// Nothing is in flight now, so the top bar reports idle (not the turn flow).
|
||||||
|
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a saved game (#20): a saved game can be deleted from the home screen
|
||||||
|
* (with a confirmation dialog), and loading a non-existent / deleted game id
|
||||||
|
* (directly or via a stale open tab) returns the user to the home screen.
|
||||||
|
*/
|
||||||
|
test.describe('History — remove a saved game & redirect (#20)', () => {
|
||||||
|
test('deleting a saved game from the home screen removes it (with confirmation)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const id = await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
|
||||||
|
// Back to the home screen, where the just-created game appears in "Saved games".
|
||||||
|
await page.getByTestId('back-to-scenarios').click();
|
||||||
|
await expect(page.getByTestId('saved-games')).toBeVisible({ timeout: 15000 });
|
||||||
|
const card = page.locator(`[data-game-id="${id}"]`);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Opening the confirm dialog…
|
||||||
|
await card.getByTestId('delete-game').click();
|
||||||
|
const dialog = page.getByTestId('delete-game-confirm-dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
// …and cancelling leaves the game in place.
|
||||||
|
await page.getByTestId('delete-game-cancel').click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Confirming the delete removes it: the DELETE succeeds, the card disappears,
|
||||||
|
// and a subsequent fetch reports the game gone.
|
||||||
|
await card.getByTestId('delete-game').click();
|
||||||
|
await expect(page.getByTestId('delete-game-confirm-dialog')).toBeVisible();
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.request().method() === 'DELETE' &&
|
||||||
|
new URL(r.url()).pathname === `/api/games/${id}`,
|
||||||
|
),
|
||||||
|
page.getByTestId('delete-game-confirm').click(),
|
||||||
|
]);
|
||||||
|
expect((await resp.json()) as { deleted: boolean }).toEqual({ deleted: true });
|
||||||
|
await expect(page.getByTestId('delete-game-confirm-dialog')).toHaveCount(0);
|
||||||
|
await expect(card).toHaveCount(0);
|
||||||
|
|
||||||
|
// The game is gone from the server too.
|
||||||
|
const status = await page.evaluate(async (gid) => {
|
||||||
|
const r = await fetch(`/api/games/${gid}`);
|
||||||
|
return r.status;
|
||||||
|
}, id);
|
||||||
|
expect(status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting an open game sends the active tab home; a deep link lands on home', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const id = await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
const origin = await page.evaluate(() => location.origin);
|
||||||
|
|
||||||
|
// Delete the game straight through the API while its tab is open. The server
|
||||||
|
// broadcasts an SSE `error` and the client's useSse onError handler returns
|
||||||
|
// this live tab to the home screen.
|
||||||
|
const delStatus = await page.evaluate(
|
||||||
|
async (gid) => (await fetch(`/api/games/${gid}`, { method: 'DELETE' })).status,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
expect(delStatus).toBe(200);
|
||||||
|
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||||
|
await waitHash(page, '#/');
|
||||||
|
|
||||||
|
// A brand-new page deep-linking to the now-deleted id also lands on home.
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.goto(`${origin}/#/game/${id}`);
|
||||||
|
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||||
|
await waitHash(page2, '#/');
|
||||||
|
await page2.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.4.0",
|
"version": "0.10.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).",
|
||||||
|
|||||||
@@ -72,4 +72,19 @@ describe('openDb', () => {
|
|||||||
expect(db.load('missing')).toBeNull();
|
expect(db.load('missing')).toBeNull();
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
db.save(makeState('a'));
|
||||||
|
db.save(makeState('b'));
|
||||||
|
|
||||||
|
expect(db.delete('nope')).toBe(false); // unknown id
|
||||||
|
expect(db.delete('a')).toBe(true); // removes a
|
||||||
|
expect(db.load('a')).toBeNull();
|
||||||
|
expect(db.load('b')?.id).toBe('b');
|
||||||
|
expect([...db.listIds()].sort()).toEqual(['b']);
|
||||||
|
|
||||||
|
expect(db.delete('a')).toBe(false); // already gone
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Db {
|
|||||||
save(state: GameState): void;
|
save(state: GameState): void;
|
||||||
load(id: string): GameState | null;
|
load(id: string): GameState | null;
|
||||||
listIds(): string[];
|
listIds(): string[];
|
||||||
|
delete(id: string): boolean;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export function openDb(filePath: string): Db {
|
|||||||
);
|
);
|
||||||
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
||||||
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
||||||
|
const del = db.prepare('DELETE FROM games WHERE id = ?');
|
||||||
return {
|
return {
|
||||||
save(state) {
|
save(state) {
|
||||||
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
||||||
@@ -45,6 +47,9 @@ export function openDb(filePath: string): Db {
|
|||||||
listIds() {
|
listIds() {
|
||||||
return (all.all() as { id: string }[]).map((r) => r.id);
|
return (all.all() as { id: string }[]).map((r) => r.id);
|
||||||
},
|
},
|
||||||
|
delete(id) {
|
||||||
|
return del.run(id).changes > 0;
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-1
@@ -1,10 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||||
import type { TurnResult } from '../shared/protocol';
|
import { makeEvent, type TurnResult } from '../shared/protocol';
|
||||||
import type { LlmClient } from './llm';
|
import type { LlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
|
buildSystemPrompt,
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
isMeaningful,
|
isMeaningful,
|
||||||
parseTurnResult,
|
parseTurnResult,
|
||||||
@@ -279,6 +280,44 @@ describe('buildUserPrompt (batched round)', () => {
|
|||||||
expect(parsed.roundInstruction).toMatch(/no action/i);
|
expect(parsed.roundInstruction).toMatch(/no action/i);
|
||||||
expect(parsed.roundInstruction).toMatch(/own momentum/i);
|
expect(parsed.roundInstruction).toMatch(/own momentum/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps recentEvents at the last 5 (in order) when the state has more than 5 events', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const events = Array.from({ length: 8 }, (_, i) =>
|
||||||
|
makeEvent(
|
||||||
|
{
|
||||||
|
nation: 'uk',
|
||||||
|
type: 'political',
|
||||||
|
title: `Event ${i + 1}`,
|
||||||
|
text: `Narrative ${i + 1}`,
|
||||||
|
outcome: 'neutral',
|
||||||
|
},
|
||||||
|
i + 1,
|
||||||
|
`1939-09-0${i + 1}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state.events = events;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||||||
|
state: { recentEvents: Array<{ title: string; turn: number; date: string }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.state.recentEvents).toHaveLength(5);
|
||||||
|
// the last 5 of the 8 input events, in order
|
||||||
|
expect(parsed.state.recentEvents[0].title).toBe(events[3].title); // 'Event 4'
|
||||||
|
expect(parsed.state.recentEvents[4].title).toBe(events[7].title); // 'Event 8'
|
||||||
|
expect(parsed.state.recentEvents.map((e) => e.title)).toEqual([
|
||||||
|
events[3].title,
|
||||||
|
events[4].title,
|
||||||
|
events[5].title,
|
||||||
|
events[6].title,
|
||||||
|
events[7].title,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
||||||
|
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queueRoundItem', () => {
|
describe('queueRoundItem', () => {
|
||||||
|
|||||||
+2
-1
@@ -59,6 +59,7 @@ export function buildSystemPrompt(preset: Preset): string {
|
|||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
'- Only change borders when war or a decisive political shift justifies it.',
|
'- Only change borders when war or a decisive political shift justifies it.',
|
||||||
'- If a field is not relevant this turn, set it to an empty array.',
|
'- If a field is not relevant this turn, set it to an empty array.',
|
||||||
@@ -95,7 +96,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
|||||||
nations: Object.fromEntries(
|
nations: Object.fromEntries(
|
||||||
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
||||||
),
|
),
|
||||||
recentEvents: state.events.slice(-10).map((e) => ({
|
recentEvents: state.events.slice(-5).map((e) => ({
|
||||||
turn: e.turn,
|
turn: e.turn,
|
||||||
date: e.date,
|
date: e.date,
|
||||||
nation: e.nation,
|
nation: e.nation,
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ app.get('/api/games/:id/map', (req, res) => {
|
|||||||
res.json({ regions: buildRegionMap(game) });
|
res.json({ regions: buildRegionMap(game) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on
|
||||||
|
// success broadcast an SSE `error` to any other open tab watching it so it
|
||||||
|
// returns home (see the `event: error` handling in src/lib/sse.ts).
|
||||||
|
app.delete('/api/games/:id', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const removed = db.delete(id);
|
||||||
|
if (!removed) return res.status(404).json({ error: 'game not found' });
|
||||||
|
broadcast(id, 'error', { message: 'game deleted' });
|
||||||
|
res.json({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Queue a free-text player action onto the pending round. No LLM call and no
|
// Queue a free-text player action onto the pending round. No LLM call and no
|
||||||
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
||||||
app.post('/api/games/:id/action', (req, res) => {
|
app.post('/api/games/:id/action', (req, res) => {
|
||||||
|
|||||||
+65
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { nationColor } from '../shared/protocol';
|
import { nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
@@ -33,6 +33,9 @@ const state: GameState = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
|
||||||
|
const regions: RegionMap = buildRegionMap(state);
|
||||||
|
|
||||||
describe('buildRegionMap', () => {
|
describe('buildRegionMap', () => {
|
||||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
||||||
const m = buildRegionMap(state);
|
const m = buildRegionMap(state);
|
||||||
@@ -44,6 +47,67 @@ describe('buildRegionMap', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('regionOwner', () => {
|
||||||
|
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
|
||||||
|
expect(regionOwner(regions, 'GBR')).toEqual({
|
||||||
|
color: nationColor('uk'),
|
||||||
|
owner: 'UK',
|
||||||
|
ownerId: 'uk',
|
||||||
|
});
|
||||||
|
expect(regionOwner(regions, 'DEU')).toEqual({
|
||||||
|
color: nationColor('de'),
|
||||||
|
owner: 'DE',
|
||||||
|
ownerId: 'de',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||||
|
expect(regionOwner(regions, 'FRA')).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, 'USA')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an empty or missing code', () => {
|
||||||
|
expect(regionOwner(regions, '')).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, null)).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #6 regression guard. The click/hover handlers resolve the owning nation from a
|
||||||
|
* map feature. MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3
|
||||||
|
* string) into a *numeric index* when it stores the source, so a feature the map
|
||||||
|
* hands back at interaction time has a number for `id` and the ISO_A3 only lives
|
||||||
|
* in `properties.regionId`. This test pins the exact data shape and proves the
|
||||||
|
* fix: resolution via `properties.regionId` works, while the pre-fix path that
|
||||||
|
* read `feature.id` does not.
|
||||||
|
*/
|
||||||
|
describe('#6 — owner must be resolved from properties.regionId, not the repacked feature.id', () => {
|
||||||
|
// The shape MapLibre returns for a source feature after setData(): `id` is a
|
||||||
|
// number (here 0, as observed in the live map), and the ISO_A3 is preserved
|
||||||
|
// only in properties (the client stamps it as `regionId`).
|
||||||
|
const repackedFeature = {
|
||||||
|
id: 0,
|
||||||
|
properties: { name: 'DE', regionId: 'DEU' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('resolves the owner from properties.regionId (the fix)', () => {
|
||||||
|
const code = typeof repackedFeature.properties.regionId === 'string' ? repackedFeature.properties.regionId : '';
|
||||||
|
expect(regionOwner(regions, code)).toEqual({
|
||||||
|
color: nationColor('de'),
|
||||||
|
owner: 'DE',
|
||||||
|
ownerId: 'de',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the old path (feature.id as a string key) does NOT resolve — the bug', () => {
|
||||||
|
// String(0) === '0' is not a valid ISO_A3 key, so the pre-fix lookup failed
|
||||||
|
// and the click popup reported "No owner" even for owned regions.
|
||||||
|
expect(regions[String(repackedFeature.id)]).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('nationColor', () => {
|
describe('nationColor', () => {
|
||||||
it('is deterministic per id and distinct across different ids', () => {
|
it('is deterministic per id and distinct across different ids', () => {
|
||||||
expect(nationColor('uk')).toBe(nationColor('uk'));
|
expect(nationColor('uk')).toBe(nationColor('uk'));
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ export interface RegionOwner {
|
|||||||
}
|
}
|
||||||
export type RegionMap = Record<string, RegionOwner>;
|
export type RegionMap = Record<string, RegionOwner>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the owner record for a map region from the authoritative RegionMap by
|
||||||
|
* its ISO_A3 code. Returns `undefined` for an empty/missing code or an unowned
|
||||||
|
* region. Pure + shared: the client resolves a clicked or hovered map feature by
|
||||||
|
* first reading the feature's stable ISO_A3 code (stored in `properties.regionId`
|
||||||
|
* by the client when it repacks the source — see WorldMap) and then calling this.
|
||||||
|
*
|
||||||
|
* The code must NOT be taken from the feature's top-level `id`: MapLibre repacks
|
||||||
|
* a GeoJSON source's `id` (an ISO_A3 string) into a numeric index when it stores
|
||||||
|
* the source, so at click/hover time `feature.id` is a number, never the ISO_A3
|
||||||
|
* — resolving from it is exactly the "no owner" bug (#6).
|
||||||
|
*/
|
||||||
|
export function regionOwner(regions: RegionMap, isoA3: string | null | undefined): RegionOwner | undefined {
|
||||||
|
if (!isoA3) return undefined;
|
||||||
|
return regions[isoA3];
|
||||||
|
}
|
||||||
|
|
||||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||||
export type SseMessage =
|
export type SseMessage =
|
||||||
| { type: 'state'; state: GameState }
|
| { type: 'state'; state: GameState }
|
||||||
@@ -124,6 +141,11 @@ export interface GameSummary {
|
|||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Response for deleting a saved game (see `DELETE /api/games/:id`). */
|
||||||
|
export interface DeleteGameResponse {
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
||||||
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
||||||
|
|||||||
+138
-60
@@ -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
|
||||||
@@ -141,6 +173,10 @@ export default function App() {
|
|||||||
const [regions, setRegions] = useState<RegionMap>({});
|
const [regions, setRegions] = useState<RegionMap>({});
|
||||||
const [advisors, setAdvisors] = useState<string[]>([]);
|
const [advisors, setAdvisors] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
// Separate from `busy`: an advisor fetch is its own LLM call. It must NOT
|
||||||
|
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
||||||
|
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
||||||
|
const [advisorBusy, setAdvisorBusy] = useState(false);
|
||||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||||
// list of saved games (newest first) shown on the home screen.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -273,6 +309,12 @@ export default function App() {
|
|||||||
useSse(game?.id ?? null, {
|
useSse(game?.id ?? null, {
|
||||||
onState: applyState,
|
onState: applyState,
|
||||||
onTurn: applyState,
|
onTurn: applyState,
|
||||||
|
// The game disappeared while this tab had it open — either deleted from
|
||||||
|
// another tab (the server broadcasts an SSE `error`) or a stale deep link to
|
||||||
|
// a missing game (the SSE connect emits `error`). Return this tab home.
|
||||||
|
onError: (msg) => {
|
||||||
|
if (/deleted|not found/i.test(msg)) latestRef.current?.goHome();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const play = useCallback(async (presetId: string) => {
|
const play = useCallback(async (presetId: string) => {
|
||||||
@@ -374,14 +416,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [game, applyState]);
|
}, [game, applyState]);
|
||||||
|
|
||||||
|
// Ask the advisor: a separate (slow) LLM call. It sets its own `advisorBusy`
|
||||||
|
// flag (distinct from the turn `busy`) and clears it in `finally`, so the flag
|
||||||
|
// is released on both success and error. Asking never advances a turn and
|
||||||
|
// never touches the turn `busy` state — "Jump forward" stays usable.
|
||||||
const askAdvisor = useCallback(
|
const askAdvisor = useCallback(
|
||||||
async (prompt?: string) => {
|
async (prompt?: string) => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
setAdvisorBusy(true);
|
||||||
try {
|
try {
|
||||||
const { suggestions } = await api.advisor(game.id, prompt);
|
const { suggestions } = await api.advisor(game.id, prompt);
|
||||||
setAdvisors(suggestions);
|
setAdvisors(suggestions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setAdvisorBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[game],
|
[game],
|
||||||
@@ -422,6 +471,29 @@ export default function App() {
|
|||||||
setAdvisors([]);
|
setAdvisors([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Delete a saved game. Removes it from the "Saved games" list, clears it if it
|
||||||
|
// was the remembered active game, and heads home if it's the one currently
|
||||||
|
// shown. The server also broadcasts an SSE `error` to any *other* open tab of
|
||||||
|
// this game, which the `useSse` `onError` handler above turns into a go-home.
|
||||||
|
const runDeleteGame = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteGame(id);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavedGames((prev) => prev.filter((g) => g.id !== id));
|
||||||
|
if (activeGameId === id) {
|
||||||
|
clearActiveGameId();
|
||||||
|
setActiveGameId(null);
|
||||||
|
}
|
||||||
|
if (game?.id === id) goHome();
|
||||||
|
toast.success('Game deleted');
|
||||||
|
},
|
||||||
|
[activeGameId, game, goHome],
|
||||||
|
);
|
||||||
|
|
||||||
// Expose the latest game + handlers to the mount-once hashchange listener.
|
// Expose the latest game + handlers to the mount-once hashchange listener.
|
||||||
latestRef.current = { game, goHome, loadGame };
|
latestRef.current = { game, goHome, loadGame };
|
||||||
|
|
||||||
@@ -434,6 +506,7 @@ export default function App() {
|
|||||||
game={game}
|
game={game}
|
||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
advisorBusy={advisorBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={runAddAction}
|
onAddAction={runAddAction}
|
||||||
onAddDiplomacy={runAddDiplomacy}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
@@ -465,6 +538,7 @@ export default function App() {
|
|||||||
savedGames={savedGames}
|
savedGames={savedGames}
|
||||||
activeGameId={activeGameId}
|
activeGameId={activeGameId}
|
||||||
onResume={resumeGame}
|
onResume={resumeGame}
|
||||||
|
onDelete={runDeleteGame}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -473,7 +547,10 @@ export default function App() {
|
|||||||
interface GameScreenProps {
|
interface GameScreenProps {
|
||||||
game: GameState;
|
game: GameState;
|
||||||
regions: RegionMap;
|
regions: RegionMap;
|
||||||
|
/** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
||||||
|
advisorBusy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAddAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onAddDiplomacy: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
@@ -485,19 +562,23 @@ 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,
|
||||||
regions,
|
regions,
|
||||||
busy,
|
busy,
|
||||||
|
advisorBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
@@ -534,11 +615,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">
|
||||||
@@ -549,13 +642,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">
|
||||||
@@ -602,17 +696,24 @@ function GameScreen({
|
|||||||
{player?.name ?? game.playerNationId}
|
{player?.name ?? game.playerNationId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Three-state live status: advisor fetch in flight → "Advisor
|
||||||
|
thinking…"; else a turn in flight → "Simulating…"; else "Idle".
|
||||||
|
Both LLM-in-flight states share the amber spinner; idle is muted. */}
|
||||||
<span
|
<span
|
||||||
data-testid="status-indicator"
|
data-testid="status-indicator"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||||
busy
|
advisorBusy || busy
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
? 'border-primary/40 bg-primary/10 text-primary'
|
||||||
: 'border-border bg-muted/40 text-muted-foreground',
|
: 'border-border bg-muted/40 text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
|
{advisorBusy || busy ? (
|
||||||
{busy ? 'Simulating…' : 'Idle'}
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CircleDot className="size-3" />
|
||||||
|
)}
|
||||||
|
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -628,13 +729,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"
|
||||||
@@ -659,9 +763,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"
|
||||||
@@ -671,6 +776,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]"
|
||||||
@@ -678,9 +785,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"
|
||||||
@@ -692,6 +800,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"
|
||||||
@@ -699,6 +809,7 @@ function GameScreen({
|
|||||||
<ActionPanel
|
<ActionPanel
|
||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
advisorBusy={advisorBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={onAddAction}
|
onAddAction={onAddAction}
|
||||||
onAddDiplomacy={onAddDiplomacy}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
@@ -721,39 +832,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ import type { GameState, RoundItem } from '@shared/types';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: GameState;
|
game: GameState;
|
||||||
/** A jump (the only LLM call) is currently in flight — disable the commit. */
|
/** A jump (the only turn LLM call) is currently in flight — disable the commit. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/**
|
||||||
|
* An advisor fetch (a separate LLM call) is in flight. Distinct from `busy`:
|
||||||
|
* it gates only the advisor UI (the "Ask advisor" button + its hint) and must
|
||||||
|
* never disable "Jump forward" or the rest of the turn flow.
|
||||||
|
*/
|
||||||
|
advisorBusy: boolean;
|
||||||
/** Advisor brainstorm suggestions from the last request. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
@@ -59,6 +65,7 @@ function describeItem(item: RoundItem, nationName: (id?: string) => string): str
|
|||||||
export default function ActionPanel({
|
export default function ActionPanel({
|
||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
|
advisorBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
@@ -194,10 +201,23 @@ export default function ActionPanel({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||||
|
disabled={advisorBusy}
|
||||||
data-testid="ask-advisor"
|
data-testid="ask-advisor"
|
||||||
>
|
>
|
||||||
Ask advisor
|
{advisorBusy && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{advisorBusy ? 'Thinking…' : 'Ask advisor'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* While the advisor (a separate LLM call) is thinking, mirror the
|
||||||
|
turn's "sim-hint" so the user knows the request is in flight. */}
|
||||||
|
{advisorBusy && (
|
||||||
|
<p
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
data-testid="advisor-hint"
|
||||||
|
>
|
||||||
|
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||||
|
The advisor is thinking… this can take a few seconds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{advisors.length > 0 && (
|
{advisors.length > 0 && (
|
||||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||||
{advisors.map((a, i) => (
|
{advisors.map((a, i) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Play } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Play, Trash2 } 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, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
import type { PresetMeta } from '@shared/types';
|
import type { PresetMeta } from '@shared/types';
|
||||||
import type { GameSummary } from '@shared/protocol';
|
import type { GameSummary } from '@shared/protocol';
|
||||||
|
|
||||||
@@ -15,12 +24,14 @@ interface Props {
|
|||||||
activeGameId?: string | null;
|
activeGameId?: string | null;
|
||||||
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
|
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
|
||||||
onResume?: (id: string) => void;
|
onResume?: (id: string) => void;
|
||||||
|
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home screen: a grid of scenario cards (each with a Play button), a prominent
|
* Home screen: a grid of scenario cards (each with a Play button), a prominent
|
||||||
* "Resume last game" affordance, and a "Saved games" section listing prior
|
* "Resume last game" affordance, and a "Saved games" section listing prior
|
||||||
* games so the user can get back into one.
|
* games so the user can get back into one — or delete it.
|
||||||
*/
|
*/
|
||||||
export default function PresetPicker({
|
export default function PresetPicker({
|
||||||
presets,
|
presets,
|
||||||
@@ -29,8 +40,14 @@ export default function PresetPicker({
|
|||||||
savedGames = [],
|
savedGames = [],
|
||||||
activeGameId,
|
activeGameId,
|
||||||
onResume,
|
onResume,
|
||||||
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||||
|
// The game awaiting a delete confirmation (null = dialog closed).
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const confirmDeleteTarget = confirmDeleteId
|
||||||
|
? savedGames.find((g) => g.id === confirmDeleteId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||||
@@ -90,7 +107,7 @@ export default function PresetPicker({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saved games: get back into any prior game. */}
|
{/* Saved games: get back into any prior game (or delete it). */}
|
||||||
{savedGames.length > 0 && (
|
{savedGames.length > 0 && (
|
||||||
<section className="mt-12" data-testid="saved-games">
|
<section className="mt-12" data-testid="saved-games">
|
||||||
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
|
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
|
||||||
@@ -100,13 +117,28 @@ export default function PresetPicker({
|
|||||||
key={g.id}
|
key={g.id}
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
data-testid="saved-game"
|
data-testid="saved-game"
|
||||||
|
data-game-id={g.id}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{g.date}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Badge>
|
{g.date}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="delete-game"
|
||||||
|
aria-label={`Delete ${g.presetTitle}`}
|
||||||
|
title="Delete this game"
|
||||||
|
onClick={() => setConfirmDeleteId(g.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
|
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -125,6 +157,40 @@ export default function PresetPicker({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirm before permanently deleting a saved game. */}
|
||||||
|
<Dialog open={confirmDeleteId !== null} onOpenChange={(o) => !o && setConfirmDeleteId(null)}>
|
||||||
|
<DialogContent className="max-w-sm" data-testid="delete-game-confirm-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this game?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{confirmDeleteTarget
|
||||||
|
? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.`
|
||||||
|
: 'This game will be permanently removed.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-testid="delete-game-cancel"
|
||||||
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="delete-game-confirm"
|
||||||
|
onClick={() => {
|
||||||
|
const id = confirmDeleteId;
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
if (id) onDelete?.(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-72
@@ -8,7 +8,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
|||||||
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
||||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||||
maplibregl.setWorkerUrl(workerUrl);
|
maplibregl.setWorkerUrl(workerUrl);
|
||||||
import type { RegionMap, RegionOwner } from '@shared/protocol';
|
import { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||||
import type { Nation } from '@shared/types';
|
import type { Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
|
|||||||
type: 'raster',
|
type: 'raster',
|
||||||
source: 'terrain',
|
source: 'terrain',
|
||||||
paint: {
|
paint: {
|
||||||
'raster-opacity': 0.5,
|
'raster-opacity': 0.15,
|
||||||
'raster-brightness-max': 0.8,
|
'raster-brightness-max': 0.8,
|
||||||
'raster-fade-duration': 200,
|
'raster-fade-duration': 200,
|
||||||
},
|
},
|
||||||
@@ -101,21 +101,36 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
|||||||
* carries its owner's stable fill color, a subtle stroke, and a flag for the
|
* carries its owner's stable fill color, a subtle stroke, and a flag for the
|
||||||
* player's own regions (rendered as a brighter, thicker border by a dedicated
|
* player's own regions (rendered as a brighter, thicker border by a dedicated
|
||||||
* layer). Unknown owners fall back to a faint neutral fill — never an error.
|
* layer). Unknown owners fall back to a faint neutral fill — never an error.
|
||||||
|
*
|
||||||
|
* Crucially, the base GeoJSON feature's top-level `id` (the ISO_A3 code) is
|
||||||
|
* stamped into `properties.regionId`, and the owner's id into `properties.ownerId`.
|
||||||
|
* MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it
|
||||||
|
* stores it, so a later click/hover event exposes that number on
|
||||||
|
* `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at
|
||||||
|
* interaction time (the root cause of #6). `properties`, however, survive into the
|
||||||
|
* rendered features, so the interaction handlers resolve the owning nation from
|
||||||
|
* `properties.regionId` via the RegionMap (see `regionOwner`).
|
||||||
*/
|
*/
|
||||||
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
||||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||||
for (const f of out.features) {
|
for (const f of out.features) {
|
||||||
f.properties = f.properties ?? {};
|
f.properties = f.properties ?? {};
|
||||||
const r = f.id ? regions[f.id as string] : undefined;
|
// The base GeoJSON `id` is the ISO_A3 code; keep it in a property that
|
||||||
|
// survives into the features MapLibre hands back on click/hover.
|
||||||
|
const code = f.id == null ? '' : String(f.id);
|
||||||
|
f.properties.regionId = code;
|
||||||
|
const r = code ? regions[code] : undefined;
|
||||||
if (r) {
|
if (r) {
|
||||||
f.properties.fill = r.color;
|
f.properties.fill = r.color;
|
||||||
f.properties.owner = r.owner;
|
f.properties.owner = r.owner;
|
||||||
f.properties.stroke = 'rgba(255,255,255,0.10)';
|
f.properties.ownerId = r.ownerId;
|
||||||
|
f.properties.stroke = 'rgba(255,255,255,0.30)';
|
||||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
f.properties.fill = 'rgba(148,163,184,0.05)';
|
f.properties.fill = 'rgba(148,163,184,0.05)';
|
||||||
f.properties.stroke = 'rgba(255,255,255,0.06)';
|
f.properties.stroke = 'rgba(255,255,255,0.14)';
|
||||||
f.properties.owner = '';
|
f.properties.owner = '';
|
||||||
|
f.properties.ownerId = '';
|
||||||
f.properties.isPlayer = 0;
|
f.properties.isPlayer = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,48 +165,20 @@ function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation):
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click popup: the full owning-nation profile with all four stats. */
|
|
||||||
function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
|
|
||||||
if (!region || !nation) {
|
|
||||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
|
||||||
}
|
|
||||||
const stats = [
|
|
||||||
statRow('Mil', nation.stats.military),
|
|
||||||
statRow('Eco', nation.stats.economy),
|
|
||||||
statRow('Stab', nation.stats.stability),
|
|
||||||
statRow('Pop', nation.stats.population),
|
|
||||||
].join('');
|
|
||||||
const regions = nation.territory.length;
|
|
||||||
return `<div class="hm-popup">
|
|
||||||
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
|
|
||||||
<div class="hm-popup-owner hm-popup-owner-big"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
|
||||||
<div class="hm-popup-sub">${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier</div>
|
|
||||||
<div class="hm-stats">${stats}</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;
|
||||||
/** All nations, for hover/click stats and color chips. */
|
/** All nations, for the hover tooltip's owner stats and color chips. */
|
||||||
nations: Record<string, Nation>;
|
nations: Record<string, Nation>;
|
||||||
/** The active scenario id, for a sensible initial camera. */
|
/** The active scenario id, for a sensible initial camera. */
|
||||||
presetId: string;
|
presetId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Called when a region is clicked, so the dossier can focus its owner. */
|
|
||||||
onFocusNation?: (id: string) => void;
|
|
||||||
/**
|
/**
|
||||||
* Called with a minimal zoom/pan API once the map is ready (and with `null`
|
* Called with a region's owning-nation id when that region is clicked, so the
|
||||||
* on unmount), so the floating zoom control can drive it.
|
* dossier switches to that nation. Not called for unowned regions (the dossier
|
||||||
|
* keeps its current target). See the component doc for the full behavior.
|
||||||
*/
|
*/
|
||||||
onMapReady?: (api: MapApi | null) => void;
|
onFocusNation?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,10 +186,18 @@ interface Props {
|
|||||||
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
||||||
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
|
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
|
||||||
* 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, a hover tooltip (owner + key stats), a **click that
|
||||||
* owner + stats, a transient flash on territory changes, and a
|
* switches the dossier to the region's owning nation** (no popup — see #7), a
|
||||||
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating
|
* transient flash on territory changes, and a per-scenario initial camera.
|
||||||
* zoom control can drive it (no MapLibre navigation controls are shown).
|
*
|
||||||
|
* Ownership for a hovered/clicked region is resolved from the authoritative
|
||||||
|
* RegionMap by the feature's stable ISO_A3 code in `properties.regionId`
|
||||||
|
* (see `regionOwner` in @shared/protocol) — never from the feature's top-level
|
||||||
|
* `id`, which MapLibre repacks into a numeric index at interaction time (#6).
|
||||||
|
* Unowned regions (no RegionMap entry) simply do not change the dossier.
|
||||||
|
*
|
||||||
|
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||||
|
* — no floating control and no external API.
|
||||||
*/
|
*/
|
||||||
export default function WorldMap({
|
export default function WorldMap({
|
||||||
regions,
|
regions,
|
||||||
@@ -211,7 +206,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 +216,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,19 +249,7 @@ 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 });
|
|
||||||
let lastHoverId: string | null = null;
|
let lastHoverId: string | null = null;
|
||||||
|
|
||||||
const loadGeo = (): Promise<FeatureCollection> =>
|
const loadGeo = (): Promise<FeatureCollection> =>
|
||||||
@@ -326,24 +306,33 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
flashApiRef.current = animateFlash;
|
flashApiRef.current = animateFlash;
|
||||||
|
|
||||||
|
// The region a hovered/clicked feature belongs to is identified by its stable
|
||||||
|
// ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`).
|
||||||
|
// We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON
|
||||||
|
// source's `id` into a numeric index when it stores the source, so the value
|
||||||
|
// exposed on `e.features[i].id` at interaction time is a number, never the
|
||||||
|
// ISO_A3 — resolving the owner from it is the "no owner" bug (#6).
|
||||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const id = f.id == null ? null : String(f.id);
|
const p = f.properties as { name?: string; regionId?: string };
|
||||||
if (id && id !== lastHoverId) {
|
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||||
lastHoverId = id;
|
if (code && code !== lastHoverId) {
|
||||||
|
lastHoverId = code;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === id);
|
// The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on
|
||||||
|
// that to recover the feature for the hover-highlight outline (the
|
||||||
|
// feature MapLibre hands us has a repacked numeric `id` we can't use).
|
||||||
|
const feat = geo?.features.find((x) => x.id != null && String(x.id) === code);
|
||||||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
||||||
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
|
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const p = f.properties as { name?: string };
|
const region = regionOwner(regionsRef.current, code);
|
||||||
const region = id ? regionsRef.current[id] : undefined;
|
|
||||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||||
hoverPopup
|
hoverPopup
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.setHTML(hoverHtml(p?.name ?? id ?? '', region, nation))
|
.setHTML(hoverHtml(p?.name ?? code, region, nation))
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
};
|
};
|
||||||
@@ -355,14 +344,17 @@ export default function WorldMap({
|
|||||||
map.getCanvas().style.cursor = '';
|
map.getCanvas().style.cursor = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||||
|
// owning nation. The owner is resolved from the stable ISO_A3 in
|
||||||
|
// `properties.regionId` via the RegionMap (never the repacked top-level
|
||||||
|
// `f.id`, #6). A region with no owner in the RegionMap (unowned territory)
|
||||||
|
// leaves the current dossier target unchanged — never a broken dossier.
|
||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const id = f.id == null ? null : String(f.id);
|
const p = f.properties as { regionId?: string };
|
||||||
const p = f.properties as { name?: string };
|
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||||
const region = id ? regionsRef.current[id] : undefined;
|
const region = regionOwner(regionsRef.current, code);
|
||||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
|
||||||
clickPopup.setLngLat(e.lngLat).setHTML(clickHtml(p?.name ?? id ?? '', region, nation)).addTo(map);
|
|
||||||
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,7 +411,7 @@ export default function WorldMap({
|
|||||||
id: FILL_LAYER,
|
id: FILL_LAYER,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: COUNTRIES_SOURCE,
|
source: COUNTRIES_SOURCE,
|
||||||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
|
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!map.getLayer(LINE_LAYER)) {
|
if (!map.getLayer(LINE_LAYER)) {
|
||||||
@@ -427,7 +419,7 @@ export default function WorldMap({
|
|||||||
id: LINE_LAYER,
|
id: LINE_LAYER,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: COUNTRIES_SOURCE,
|
source: COUNTRIES_SOURCE,
|
||||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
|
paint: { 'line-color': ['get', 'stroke'], 'line-width': 1.25, 'line-opacity': 0.95 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||||
@@ -482,7 +474,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) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
AddDiplomacyResponse,
|
AddDiplomacyResponse,
|
||||||
AdvisorResponse,
|
AdvisorResponse,
|
||||||
CreateGameResponse,
|
CreateGameResponse,
|
||||||
|
DeleteGameResponse,
|
||||||
GameSummary,
|
GameSummary,
|
||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
@@ -33,6 +34,9 @@ export const api = {
|
|||||||
body: JSON.stringify({ presetId, playerNationId }),
|
body: JSON.stringify({ presetId, playerNationId }),
|
||||||
}),
|
}),
|
||||||
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
||||||
|
// Delete a saved game entirely. Fast, non-LLM. Returns { deleted: true }.
|
||||||
|
deleteGame: (id: string) =>
|
||||||
|
req<DeleteGameResponse>(`/api/games/${id}`, { method: 'DELETE' }),
|
||||||
// Every saved game as a lightweight summary, newest-updated first (for the
|
// Every saved game as a lightweight summary, newest-updated first (for the
|
||||||
// home screen's "Saved games" section / resume affordances).
|
// home screen's "Saved games" section / resume affordances).
|
||||||
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol';
|
|||||||
interface SseHandlers {
|
interface SseHandlers {
|
||||||
onState?: (state: GameState) => void;
|
onState?: (state: GameState) => void;
|
||||||
onTurn?: (state: GameState, result: TurnResult) => void;
|
onTurn?: (state: GameState, result: TurnResult) => void;
|
||||||
|
/**
|
||||||
|
* Fired for a server-sent NAMED `event: error` (a MessageEvent with a JSON
|
||||||
|
* body `{ message }`) — e.g. the game was deleted or no longer exists. Pure
|
||||||
|
* connection errors (no data) are ignored and EventSource auto-reconnects.
|
||||||
|
*/
|
||||||
|
onError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,8 +43,19 @@ export function useSse(id: string | null, handlers: SseHandlers): void {
|
|||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const onError = (e: Event) => {
|
||||||
|
if (e instanceof MessageEvent && e.data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data) as { message?: string };
|
||||||
|
if (d.message) handlersRef.current.onError?.(d.message);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
es.addEventListener('state', onState);
|
es.addEventListener('state', onState);
|
||||||
es.addEventListener('turn', onTurn);
|
es.addEventListener('turn', onTurn);
|
||||||
|
es.addEventListener('error', onError);
|
||||||
return () => es.close();
|
return () => es.close();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user