Compare commits

...
19 Commits
Author SHA1 Message Date
kamilkrzywda db46e1a5ec Merge pull request 'Use real historical sub-national regions instead of the geometric grid (v0.13.0)' (#27) from feat/historical-regions into master
CI / build + unit + e2e (push) Successful in 1m44s
2026-10-12 10:43:09 +02:00
kamilkrzywda 99188b5b15 Replace geometric sub-regions with real historical regions (v0.13.0)
CI / build + unit + e2e (pull_request) Successful in 2m34s
Part of #16
2026-10-12 08:33:07 +00:00
kamilkrzywda 0ebdb36daa Merge pull request 'Switch map to sub-national regions (v0.12.0)' (#26) from feat/sub-national-map into master
CI / build + unit + e2e (push) Successful in 1m20s
2026-10-12 03:13:22 +02:00
kamilkrzywda 06cdbf3d81 Switch map to sub-national regions (v0.12.0)
CI / build + unit + e2e (pull_request) Successful in 1m18s
Part of #16
2026-10-12 01:07:33 +00:00
kamilkrzywda 344b5b4a5e Merge pull request 'Add sub-national regional dataset + generator (v0.11.0)' (#25) from feat/sub-national-regions into master
CI / build + unit + e2e (push) Successful in 1m21s
2026-10-12 02:47:21 +02:00
kamilkrzywda d5285ce28d Add sub-national regional dataset + generator (v0.11.0)
CI / build + unit + e2e (pull_request) Successful in 1m21s
Fixes #16
2026-10-12 00:33:11 +00:00
kamilkrzywda 8252b665b5 Merge pull request 'Fix map coastal-water bleed-through and border lines (v0.10.0)' (#24) from feat/map-border-style into master
CI / build + unit + e2e (push) Successful in 1m15s
2026-10-11 23:32:46 +02:00
kamilkrzywda 7d50cba061 Fix map coastal-water bleed-through and border lines (v0.10.0)
CI / build + unit + e2e (pull_request) Successful in 2m13s
Fixes #2
2026-10-11 21:26:25 +00:00
kamilkrzywda e70f65f112 Merge pull request 'Reduce turn latency: cap recent events (10→5) + concise-reasoning constraint (v0.9.0)' (#23) from feat/turn-latency-hardening into master
CI / build + unit + e2e (push) Successful in 2m16s
2026-10-11 23:03:53 +02:00
kamilkrzywda e38cc5933f Reduce turn latency: cap recent events (10→5) + concise-reasoning constraint (v0.9.0)
CI / build + unit + e2e (pull_request) Successful in 2m9s
Fixes #22
2026-10-11 20:59:18 +00:00
kamilkrzywda 858b901b8d Merge pull request 'v0.8.0 — remove a saved game & redirect on missing/deleted game (#20)' (#21) from feat/delete-saved-games into master
CI / build + unit + e2e (push) Successful in 1m39s
2026-10-11 22:35:58 +02:00
kamilkrzywda 17d6cecab9 feat: remove a saved game & redirect on missing/deleted game (#20)
CI / build + unit + e2e (pull_request) Successful in 1m14s
- DELETE /api/games/:id route + Db.delete() (404 when absent)
- PresetPicker: per-saved-game trash button + confirm dialog (Cancel/Delete)
- App: runDeleteGame; useSse onError -> goHome on 'deleted'/'not found'
  (a deleted/missing game now returns the live tab and any deep link home)
- DeleteGameResponse in shared/protocol.ts; api.deleteGame()
- unit test (Db.delete) + 2 e2e tests (home-screen delete; in-flight delete
  returns the active tab home and a fresh deep link lands on home)
- version 0.7.0 -> 0.8.0 + changelog

Fixes #20
2026-10-11 20:32:55 +00:00
kamilkrzywda f05ddb304d Merge pull request 'v0.7.0 — map click → dossier target; fix "no owner"' (#19) from map-click-dossier into master
CI / build + unit + e2e (push) Successful in 1m16s
2026-10-11 21:30:38 +02:00
kamilkrzywda eadae4dc4f map: click a region to switch the dossier to its owner; fix "no owner" (v0.7.0)
CI / build + unit + e2e (pull_request) Successful in 1m58s
2026-10-11 19:22:33 +00:00
kamilkrzywda 67f48fb99c Merge pull request 'v0.6.0 — advisor "thinking" indicator' (#18) from advisor-thinking into master
CI / build + unit + e2e (push) Successful in 2m5s
2026-10-11 20:38:56 +02:00
kamilkrzywda c19db2c118 advisor: show a "thinking" indicator during advisor fetches (v0.6.0)
CI / build + unit + e2e (pull_request) Successful in 1m33s
2026-10-11 18:32:09 +00:00
kamilkrzywda 977bf584ce Merge pull request 'v0.5.0 — HUD chrome: remove Map window, pin top bar, dockable left/right panels' (#17) from hud-dock into master
CI / build + unit + e2e (push) Successful in 2m12s
2026-10-11 19:49:20 +02:00
kamilkrzywda 6b167cb435 hud chrome: remove Map window, pin top bar, dockable left/right panels (v0.5.0)
CI / build + unit + e2e (pull_request) Successful in 2m18s
2026-10-11 17:40:01 +00:00
kamilkrzywda 762c120bbd Merge pull request 'v0.4.0 — fix queue-add race + per-item remove button' (#14) from queue-add-fix into master
CI / build + unit + e2e (push) Successful in 1m53s
2026-10-11 18:18:47 +02:00
24 changed files with 1605 additions and 231 deletions
+40 -6
View File
@@ -27,8 +27,10 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
- Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth
from `public/countries.geo.json` (`id` = ISO_A3)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
choropleth from `public/regions.geo.json` (2,597 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
`properties.parent` = the country's ISO_A3). Ownership and the LLM stay at **country**
granularity, so a sub-region inherits its parent country's owner color (see #16).
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
**injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport
(`LLM_STUB=1`) is used by all tests/e2e.
@@ -48,7 +50,8 @@ history/
├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest)
├── public/
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
│ └── regions.geo.json # 2,597 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
@@ -72,10 +75,11 @@ history/
│ └── utils.ts # cn()
└── components/
├── 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
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
├── 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
└── ui/ # 14 shadcn/ui primitives
```
@@ -119,6 +123,10 @@ history/
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
trash button per row with a confirm dialog); like the adds it never advances the turn.
- **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
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
@@ -135,6 +143,22 @@ history/
(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
server's `RegionMap` and any client coloring so the choropleth always agrees.
- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
**parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records
which region was clicked for a small dossier label); the manual "Inspect" selector and a map click
both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved
from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's
`properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the
sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level
`id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region
`id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave
the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable
tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution.
**PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the
LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's
color, and a whole-country transfer flips all its sub-regions at once (PR 3 will make sub-regions
individually ownable).
- **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
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
@@ -142,6 +166,15 @@ history/
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
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
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.
@@ -214,5 +247,6 @@ history/
caught.
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
- The `dist/` client is committed to the build output only; `public/countries.geo.json` is copied
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).
- The `dist/` client is committed to the build output only; the `public/` geo files
(`regions.geo.json` — the choropleth — and `countries.geo.json`) are copied into `dist/` by Vite
at build time, so the prod image serves them from `dist/` (no separate copy).
+55
View File
@@ -2,6 +2,61 @@
All notable changes to **History** are listed here, newest first.
## v0.13.0 — 2026-10-12 08:30
- **Replaced the geometric sub-region grid with real named sub-national administrative regions (#16, PR 2).** The choropleth source `public/regions.geo.json` now contains real named administrative regions — states, provinces, governorates, oblasts, … (Natural Earth admin-1, 1:10m) — instead of the abstract ~100–200 km geometric grid. Each region keeps a stable `id` and a `name`. The map's data contract is **unchanged** (`properties.parent` → the parent country's ISO_A3, `properties.name` → the region name), so no change to the map code or the LLM contract: `WorldMap.tsx`, `App.tsx`, `shared/protocol.ts`, and `server/map.ts` all keep working as-is.
- **Generator rewritten** — `scripts/generate-regions.mjs` (same path, dependency-free, `npm run generate:regions`) now groups the admin-1 features by their `adm0_a3` (the parent's ISO_A3), keeps only parents in the 177-country set, and id's each region with its ISO 3166-2 code when present (e.g. `US-CA`, `FR-75`, `RU-MOW`) else `<ISO_A3>-<nn>`; within a country, ids are disambiguated with a `-2/-3/…` suffix on collision (ISO 3166-2 codes are not always unique within a country — e.g. all nine of Bosnia's entities report `BA-BIH`). A country with no admin-1 data would collapse to one whole-country region `<ISO_A3>-01` (0 are needed at 1:10m, which covers all 177). Coordinates are rounded to 4 decimal places, and the file is Douglas–Peucker-simplified only if it exceeds the ~8 MB guard. Output is sorted by (parent, id) and fully deterministic.
- **The dataset is 4,138 regions covering all 177 countries**, 2,831 KB (2.76 MB) after Douglas–Peucker (the 4-dp base is ~23 MB). Per-country region count: min 1 / median 15 / max 232 (United Kingdom). There are **0 whole-country fallbacks** — 1:10m admin-1 covers every one of the 177 (the five single-region countries are SOL, SAH, CYN, PRI, FLK). Samples: `US-CA` California and `US-NY` New York (US); `FR-75` Paris and `FR-13` Bouches-du-Rhône (France); `RU-MOW` Moskovskaya (Russia).
- **Validation test updated** — `scripts/regions.test.ts` was rewritten for the new dataset: well-formed features, globally unique ids, all 177 countries covered (≥1 region each), sane region areas (median ≥ 100 km², no non-Antarctica region ≥ 5,000,000 km² — Antarctica is exempt because its full-360° wrap ring breaks the planar area model), and each country's region area summing to within [40%, 175%] of its own area (median within [70%, 130%] — the band is wider than a naive [40%, 110%] because Natural Earth's admin-1 polygons are digitized independently of the admin-0 country polygons and are not clipped to them). Runs in O(total vertices).
- Part of #16.
## v0.12.0 — 2026-10-12 01:03
- **Map now renders the sub-national regional dataset (#16, PR 2).** The choropleth source switches from `public/countries.geo.json` (177 countries) to `public/regions.geo.json` (**2,597 sub-regions**): each country is subdivided into ~100–200 km regions, shown as a finer choropleth with thin internal border lines. The MapLibre source/layer ids are renamed `countries`/`countries-fill`/`countries-line` → `regions`/`regions-fill`/`regions-line`, and the border line is thinned to `0.75` (from `1.25`) so the subdivision reads within each country.
- **Ownership stays at country granularity.** A nation still owns whole countries, so every sub-region of a country shares that country's owning-nation color (the fill is "same colors as the country map, but subdivided"). The owner of a sub-region is resolved from the RegionMap via the feature's `properties.parent` (the parent country's ISO_A3) — never the sub-region id (`FRA-03`) nor the repacked top-level `feature.id`. Clicking a sub-region switches the dossier to its parent country's owning nation (unchanged behavior); the hover tooltip now also shows the sub-region name + its parent country; a small "Selected …" label appears in the dossier after a map click.
- **New shared helper `mapFeatureOwner(regions, feature)`** in `@shared/protocol` — the single source of truth for resolving a rendered map feature's owner via `properties.parent` → `RegionMap[parent]` (built on the existing `regionOwner` key lookup). The territory-change **flash** now expands a changed country to **all** of its sub-regions (via the new `featuresByParents`), so a whole-country transfer still pulses correctly at the finer resolution.
- **No ownership-model or LLM change** — `buildRegionMap`/`RegionMap` remain ISO_A3-keyed and the engine still references whole countries; this PR only changes what the map draws. Sub-region-level ownership (individual regions changing hands) is PR 3.
- **Tests** — new `mapFeatureOwner` unit tests and an updated #6 regression guard pinning the sub-region feature shape (numeric `id`, `regionId` = sub-region code, `parent` = ISO_A3) and proving resolution via `parent` while the sub-region id and repacked id do not resolve (55 unit tests). The e2e suite needed no changes: it asserts on the legend (derived from the ISO_A3 RegionMap) and screen structure, not on map geometry.
- Part of #16.
## v0.11.0 — 2026-10-12 00:31
- **Sub-national region dataset + generator (#16, foundation only — no app behavior change).** A new dependency-free `scripts/generate-regions.mjs` (run via `npm run generate:regions`) splits each of the 177 countries in `public/countries.geo.json` into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed `public/regions.geo.json` (**2,597 regions**, ~755 KB). Each region is id'd `<ISO_A3>-<nn>` (nn = 1..k in row-major order, capped at 36 per country) with `properties = { name, parent }`, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256).
- **Validation test** — new self-contained `scripts/regions.test.ts` (picked up by `npm test`) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches `^[A-Z]{3}-\d+$` and whose `parent` is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on.
- Fixes #16.
## 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
- **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
View File
@@ -419,3 +419,227 @@ test.describe('History — URL deep-linking (#/game/<id>)', () => {
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
View File
@@ -30,6 +30,7 @@ export default tseslint.config(
'vitest.config.ts',
'playwright.config.ts',
'scripts/**/*.mjs',
'scripts/**/*.ts',
],
languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
rules: {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.4.0",
"version": "0.13.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -9,6 +9,7 @@
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
"start": "node server/dist/index.js",
"test": "vitest run",
"generate:regions": "node scripts/generate-regions.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"e2e": "playwright test"
File diff suppressed because one or more lines are too long
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* scripts/generate-regions.mjs — dependency-free generator for `public/regions.geo.json`.
*
* Builds the sub-national region choropleth from REAL named administrative
* regions (Natural Earth admin-1: states, provinces, governorates, oblasts, …)
* instead of the old geometric grid the user rejected ("some stupid geometric
* grid" — it looked abstract, not like recognizable territories).
*
* Sources (both GeoJSON):
* (a) the Natural Earth admin-1 file — real, named sub-national boundaries.
* It is read from a NON-committed temp path (default /tmp/ne10m_admin1.geojson,
* override with the NE_ADMIN1 env var) so the repo only stores the derived
* output, not the 40 MB source. Natural Earth is public domain (CC0).
* (b) the committed `public/countries.geo.json` (177 countries) — the
* authoritative ISO_A3 set, the country names, and the whole-country
* geometries used as the fallback for any country with no admin-1 data.
*
* Output contract (UNCHANGED from the old generator, so `WorldMap.tsx`,
* `App.tsx`, `shared/protocol.ts` and `server/map.ts` all keep working —
* ownership is resolved via `properties.parent`, the parent country's ISO_A3):
* - a GeoJSON FeatureCollection
* - each feature: a unique top-level `id`, `properties = { name, parent }`
* (`parent` = the country ISO_A3 = the RegionMap key; `name` = the region's
* human name), and a Polygon/MultiPolygon geometry.
*
* For each country in `countries.geo.json` (in file order, deterministic):
* - if it has admin-1 sub-regions -> emit each as a feature, in the source
* file's order. Region `id` = its ISO 3166-2 code (e.g. `US-CA`, `FR-75`,
* `RU-MOW`) when present, else `<ISO_A3>-<nn>` (1-based index in file
* order). Because ISO 3166-2 codes are not always unique WITHIN a country
* (e.g. all nine of Bosnia's entities report `BA-BIH`), ids are
* disambiguated with a `-2/-3/…` suffix on collision (BA-BIH, BA-BIH-2, …).
* - if it has none (tiny / not covered by admin-1) -> emit ONE whole-country
* region: `id = <ISO_A3>-01`, `name` = the country's name, geometry = the
* country's geometry.
*
* Every coordinate is rounded to 4 decimal places (0.0001° ≈ 11 m). If the
* resulting file exceeds ~8 MB, a light Douglas–Peucker pass (0.05°, escalating
* through a small table if still too big) trims it under the guard while keeping
* shapes recognizable, and the before/after sizes are reported. Features are
* sorted by (parent, id). Fully deterministic: same inputs -> same bytes.
*
* Run: `npm run generate:regions`
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
// --- parameters ----------------------------------------------------------------
const SIZE_GUARD_KB = 8192; // ~8 MB file-size guard (task spec)
const SIZE_TARGET_KB = 6144; // land under ~6 MB once simplified (margin under the guard)
const DP_STEPS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]; // Douglas–Peucker tolerances (degrees)
const R4 = 4; // base coordinate precision (decimal places, ≈11 m)
// Natural Earth admin-1 source (NON-committed temp file; override with NE_ADMIN1).
const INPUT = process.env.NE_ADMIN1 || '/tmp/ne10m_admin1.geojson';
// --- small geo helpers ---------------------------------------------------------
const rN = (v, N) => Math.round(v * Math.pow(10, N)) / Math.pow(10, N);
/** Round a ring to N decimals, drop consecutive duplicates, and close it. */
function roundRing(ring, N) {
const out = [];
for (const [x, y] of ring) {
const p = [rN(x, N), rN(y, N)];
const last = out[out.length - 1];
if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p);
}
// drop a trailing point equal to the first (a redundant close), then re-close
if (
out.length > 1 &&
out[0][0] === out[out.length - 1][0] &&
out[0][1] === out[out.length - 1][1]
)
out.pop();
if (out.length < 3) return out; // degenerate; caller drops it
return [...out, [...out[0]]];
}
/** Perpendicular distance (degrees) from point p to the segment a→b. */
function perpDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const l2 = dx * dx + dy * dy;
if (l2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/** Douglas–Peucker simplification of an OPEN point sequence (keeps endpoints). */
function dpOpen(pts, tol) {
if (pts.length < 3) return pts;
let maxD = 0;
let idx = 0;
const a = pts[0];
const b = pts[pts.length - 1];
for (let i = 1; i < pts.length - 1; i++) {
const d = perpDist(pts[i], a, b);
if (d > maxD) {
maxD = d;
idx = i;
}
}
if (maxD > tol) {
const left = dpOpen(pts.slice(0, idx + 1), tol);
const right = dpOpen(pts.slice(idx), tol);
return left.concat(right.slice(1));
}
return [a, b];
}
/** Douglas–Peucker a CLOSED ring (first == last) at tolerance `tol` (0 = skip). */
function dpRing(ring, tol) {
if (tol <= 0 || ring.length < 3) return ring;
let pts = ring;
if (
pts[0][0] === pts[pts.length - 1][0] &&
pts[0][1] === pts[pts.length - 1][1]
)
pts = pts.slice(0, -1);
const simplified = dpOpen(pts, tol);
return [...simplified, [...simplified[0]]];
}
/** Round (and optionally simplify) every ring of a Polygon/MultiPolygon geometry. */
function transformGeom(geom, tol) {
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
const out = polys
.map((poly) => poly.map((ring) => roundRing(ring, R4)).map((ring) => (tol > 0 ? dpRing(ring, tol) : ring)))
.filter((poly) => poly.length >= 1 && poly.every((ring) => ring.length >= 3));
if (!out.length) return geom; // nothing survived simplification — keep the rounded original
if (geom.type === 'Polygon') return { type: 'Polygon', coordinates: out[0] };
return { type: 'MultiPolygon', coordinates: out };
}
// --- main ----------------------------------------------------------------------
function main() {
const countries = JSON.parse(readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'));
const admin1 = JSON.parse(readFileSync(INPUT, 'utf8'));
const cset = new Set(countries.features.map((c) => c.id));
// Group admin-1 features by parent ISO_A3 (their `adm0_a3`), in file order,
// keeping only parents that are one of our 177 countries.
const byParent = new Map();
for (const f of admin1.features) {
const p = f.properties.adm0_a3;
if (typeof p === 'string' && cset.has(p)) {
if (!byParent.has(p)) byParent.set(p, []);
byParent.get(p).push(f);
}
}
// Build the base specs (raw geometry + unique id + name + parent) in
// countries.geo.json file order, then per-region source order — deterministic.
const specs = [];
const seenIds = new Set();
const nextId = (base) => {
let id = base;
let k = 1;
while (seenIds.has(id)) {
k += 1;
id = `${base}-${k}`;
}
seenIds.add(id);
return id;
};
let fallbackCount = 0;
const fallbackCountries = [];
for (const c of countries.features) {
const regs = byParent.get(c.id) ?? [];
if (regs.length) {
let nn = 0;
for (const r of regs) {
nn += 1;
const iso = r.properties.iso_3166_2;
const base = typeof iso === 'string' && iso ? iso : `${c.id}-${String(nn).padStart(2, '0')}`;
const name = typeof r.properties.name === 'string' && r.properties.name ? r.properties.name : c.properties.name;
specs.push({ id: nextId(base), name, parent: c.id, geom: r.geometry });
}
} else {
fallbackCount += 1;
fallbackCountries.push(c.id);
specs.push({ id: nextId(`${c.id}-01`), name: c.properties.name, parent: c.id, geom: c.geometry });
}
}
const sortById = (a, b) => (a.properties.parent !== b.properties.parent ? (a.properties.parent < b.properties.parent ? -1 : 1) : a.id.localeCompare(b.id, 'en', { numeric: true }));
const build = (tol) => {
const fc = {
type: 'FeatureCollection',
features: specs.map((s) => ({
type: 'Feature',
id: s.id,
properties: { name: s.name, parent: s.parent },
geometry: transformGeom(s.geom, tol),
})),
};
fc.features.sort(sortById);
return fc;
};
// Base: 4 decimal places, no simplification.
let fc = build(0);
let json = JSON.stringify(fc);
let sizeKB = Buffer.byteLength(json) / 1024;
console.log(`base (4dp, no DP): ${sizeKB.toFixed(1)} KB (${fc.features.length} features)`);
// File-size guard: if the base is over the guard, simplify until it is under
// the target (or we exhaust the tolerance table).
let usedTol = 0;
if (sizeKB > SIZE_GUARD_KB) {
for (const t of DP_STEPS) {
fc = build(t);
json = JSON.stringify(fc);
sizeKB = Buffer.byteLength(json) / 1024;
usedTol = t;
console.log(`DP ${t}: ${sizeKB.toFixed(1)} KB`);
if (sizeKB < SIZE_TARGET_KB) break;
}
}
writeFileSync(path.join(root, 'public', 'regions.geo.json'), json + '\n');
// --- summary ----------------------------------------------------------------
const counts = new Map();
for (const f of fc.features) counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
const sortedCounts = [...counts.values()].sort((a, b) => a - b);
const median =
sortedCounts.length % 2
? sortedCounts[Math.floor(sortedCounts.length / 2)]
: (sortedCounts[sortedCounts.length / 2 - 1] + sortedCounts[sortedCounts.length / 2]) / 2;
const maxCountry = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? '';
const finalKB = Buffer.byteLength(json) / 1024;
console.log(`\nwrote public/regions.geo.json — ${finalKB.toFixed(1)} KB (${(finalKB / 1024).toFixed(2)} MB)`);
console.log(`features: ${fc.features.length} total across ${counts.size} of ${countries.features.length} countries`);
console.log(`per-country count: min ${sortedCounts[0]} / median ${median} / max ${sortedCounts[sortedCounts.length - 1]}`);
console.log(`max-count country: ${maxCountry[0]} (${maxCountryName}) with ${maxCountry[1]}`);
if (usedTol) console.log(`simplification: Douglas–Peucker @ ${usedTol}° (base was ${SIZE_GUARD_KB}+ KB)`);
console.log(`whole-country fallback: ${fallbackCount} ${fallbackCount ? `(${fallbackCountries.join(', ')})` : '(none)'}`);
}
main();
+227
View File
@@ -0,0 +1,227 @@
/**
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
*
* The dataset is now REAL named sub-national administrative regions (Natural
* Earth admin-1: states, provinces, governorates, oblasts, …) rather than the
* old geometric grid — one feature per named region, each carrying
* `properties = { name, parent }` where `parent` is the country's ISO_A3 (the
* RegionMap key the map resolves ownership through). Tiny or uncovered
* countries collapse to a single whole-country region (`<ISO_A3>-01`).
*
* This test is self-contained (no import of the generator): it re-implements
* the same planar area model the generator/analysis use, so the area-based
* assertions measure the same quantity:
*
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
*
* with a shoelace per OUTER ring, each ring measured at its own mean latitude.
* Outer rings only — a region's inner rings (holes) are lakes and are ignored
* by design, the same way the coverage band already absorbed them.
*
* It reads each file exactly once and runs in O(total vertices).
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
// --- area model (planar approximation; matches scripts/generate-regions.mjs) --
const KM_LON = 111.32;
const KM_LAT = 110.574;
const rad = (deg: number) => (deg * Math.PI) / 180;
type Ring = [number, number][];
type Geometry =
| { type: 'Polygon'; coordinates: Ring[] }
| { type: 'MultiPolygon'; coordinates: Ring[][] };
function ringAreaKm2(ring: Ring, midLat: number): number {
const kx = KM_LON * Math.cos(rad(midLat));
let a = 0;
const n = ring.length;
for (let i = 0; i < n; i++) {
const [lon1, lat1] = ring[i];
const [lon2, lat2] = ring[(i + 1) % n];
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
}
return Math.abs(a / 2);
}
function outerRings(geom: Geometry): Ring[] {
if (geom.type === 'Polygon') return [geom.coordinates[0]];
if (geom.type === 'MultiPolygon') return geom.coordinates.map((p) => p[0]);
return [];
}
/** Sum of a feature's outer-ring areas, each ring at its own mean latitude. */
function featureAreaKm2(geom: Geometry): number {
let total = 0;
for (const ring of outerRings(geom)) {
let lat = 0;
for (const [, la] of ring) lat += la;
total += ringAreaKm2(ring, ring.length ? lat / ring.length : 0);
}
return total;
}
function median(nums: number[]): number {
if (!nums.length) return 0;
const s = [...nums].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
// ------------------------------------------------------------------------------
interface RegionFeature {
type: 'Feature';
id: string;
properties: { name: string; parent: string };
geometry: Geometry;
}
interface CountryFeature {
type: 'Feature';
id: string;
properties: { name: string };
geometry: Geometry;
}
// Read each file exactly once.
const regions = JSON.parse(
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
) as { type: 'FeatureCollection'; features: RegionFeature[] };
const countries = JSON.parse(
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
) as { type: 'FeatureCollection'; features: CountryFeature[] };
// Precompute once (O(total vertices)).
const countryIds = new Set(countries.features.map((c) => c.id));
const countryArea = new Map<string, number>();
for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry));
const regionArea = new Map<string, number>();
for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry));
const sumByParent = new Map<string, number>();
for (const f of regions.features)
sumByParent.set(f.properties.parent, (sumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0));
const countByParent = new Map<string, number>();
for (const f of regions.features)
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
describe('regions.geo.json', () => {
it('is a valid GeoJSON FeatureCollection of well-formed features', () => {
expect(regions.type).toBe('FeatureCollection');
expect(Array.isArray(regions.features)).toBe(true);
expect(regions.features.length).toBeGreaterThan(0);
for (const f of regions.features) {
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
expect(typeof f.id, `${f.id ?? '?'}: id must be a string`).toBe('string');
expect(f.id.length, `${f.id ?? '?'}: empty id`).toBeGreaterThan(0);
expect(typeof f.properties?.name, `${f.id}: missing name`).toBe('string');
expect(f.properties.name.length, `${f.id}: empty name`).toBeGreaterThan(0);
expect(typeof f.properties.parent, `${f.id}: missing parent`).toBe('string');
expect(countryIds.has(f.properties.parent), `${f.id}: unknown parent ${f.properties.parent}`).toBe(true);
const g = f.geometry;
expect(
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
`${f.id}: bad geometry type ${g?.type}`,
).toBe(true);
if (g) {
const polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates;
for (const poly of polys)
for (const ring of poly)
expect(ring.length, `${f.id}: ring with ${ring.length} pts`).toBeGreaterThanOrEqual(3);
}
}
});
it('has globally unique feature ids (ISO 3166-2 disambiguated on within-country collision)', () => {
const seen = new Set<string>();
const dups = new Set<string>();
for (const f of regions.features) {
if (seen.has(f.id)) dups.add(f.id);
seen.add(f.id);
}
expect(
dups.size,
`duplicate ids: ${[...dups].slice(0, 10).join(', ')}`,
).toBe(0);
expect(seen.size).toBe(regions.features.length);
});
it('covers every one of the 177 countries with at least one region', () => {
const counts = countries.features.map((c) => countByParent.get(c.id) ?? 0);
for (const c of countries.features) {
expect(countByParent.get(c.id) ?? 0, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
}
const min = Math.min(...counts);
const max = Math.max(...counts);
const med = median(counts);
const maxCountry = [...countByParent.entries()].sort((a, b) => b[1] - a[1])[0];
const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? '';
console.log(
`\n per-country region count: min ${min} / median ${med} / max ${max}` +
` (max: ${maxCountry[0]} ${maxCountryName} with ${maxCountry[1]})`,
);
});
it('has sane region areas: median >= 100 km2, and no non-Antarctica region >= 5,000,000 km2', () => {
const areas = regions.features.map((f) => regionArea.get(f.id) ?? 0);
const med = median(areas);
const maxRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) > (regionArea.get(m.id) ?? 0) ? f : m));
const minRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) < (regionArea.get(m.id) ?? 0) ? f : m));
const tiny = areas.filter((a) => a < 1).length;
// Antarctica (ATA) is a full-360° wrap ring, so the planar model can't
// measure it (it reports ~21.8M km2 for a ~14M km2 landmass) — exempt it.
for (const f of regions.features) {
if (f.properties.parent === 'ATA') continue;
const a = regionArea.get(f.id) ?? 0;
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km2 is absurdly large`).toBe(true);
}
expect(med, `median region area ${med.toFixed(1)} km2 is too small`).toBeGreaterThanOrEqual(100);
console.log(
`\n region area: min ${minRegion.id} (${minRegion.properties.name}) ≈ ${(regionArea.get(minRegion.id) ?? 0).toExponential(2)} km2` +
` / median ${med.toLocaleString('en-US')} km2` +
` / max ${maxRegion.id} (${maxRegion.properties.name}) ≈ ${Math.round(regionArea.get(maxRegion.id) ?? 0).toLocaleString('en-US')} km2` +
` — ${tiny} regions < 1 km2 (legit micro-regions: tiny islands, small urban districts, degenerate disputed enclaves)`,
);
});
it('sums to within [40%, 175%] of each country area (median in [70%, 130%])', () => {
// The band is a planar-approximation sanity check. It is wider than a naive
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
// independently of the admin-0 country polygons (they are NOT clipped to
// them), so for a handful of small/island nations the region union
// over/under-fills the country: measured range is ~[52%, 151%]
// (Brunei under-fills, Vanuatu over-fills). The band brackets that with
// margin while still catching a genuinely missing or doubled-up region.
const ratios: number[] = [];
let minRatio = Infinity;
let maxRatio = -Infinity;
let minId = '';
let maxId = '';
for (const c of countries.features) {
const ca = countryArea.get(c.id) ?? 0;
if (ca <= 0) continue;
const ratio = (sumByParent.get(c.id) ?? 0) / ca;
ratios.push(ratio);
if (ratio < minRatio) { minRatio = ratio; minId = c.id; }
if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; }
expect(
ratio >= 0.4 && ratio <= 1.75,
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 175%]`,
).toBe(true);
}
const medRatio = median(ratios);
expect(
medRatio >= 0.7 && medRatio <= 1.3,
`median coverage ${(100 * medRatio).toFixed(1)}% is outside [70%, 130%]`,
).toBe(true);
console.log(
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
` / median ${(100 * medRatio).toFixed(1)}%` +
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–175%)`,
);
});
});
+15
View File
@@ -72,4 +72,19 @@ describe('openDb', () => {
expect(db.load('missing')).toBeNull();
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();
});
});
+5
View File
@@ -8,6 +8,7 @@ export interface Db {
save(state: GameState): void;
load(id: string): GameState | null;
listIds(): string[];
delete(id: string): boolean;
close(): void;
}
@@ -29,6 +30,7 @@ export function openDb(filePath: string): Db {
);
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
const del = db.prepare('DELETE FROM games WHERE id = ?');
return {
save(state) {
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
@@ -45,6 +47,9 @@ export function openDb(filePath: string): Db {
listIds() {
return (all.all() as { id: string }[]).map((r) => r.id);
},
delete(id) {
return del.run(id).changes > 0;
},
close() {
db.close();
},
+40 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
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 { makeStubLlmClient } from './stub';
import {
applyTurnResult,
buildSystemPrompt,
buildUserPrompt,
isMeaningful,
parseTurnResult,
@@ -279,6 +280,44 @@ describe('buildUserPrompt (batched round)', () => {
expect(parsed.roundInstruction).toMatch(/no action/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', () => {
+2 -1
View File
@@ -59,6 +59,7 @@ export function buildSystemPrompt(preset: Preset): string {
'HARD CONSTRAINTS',
'- 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.',
'- 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.',
'- 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.',
@@ -95,7 +96,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
nations: Object.fromEntries(
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,
date: e.date,
nation: e.nation,
+11
View File
@@ -156,6 +156,17 @@ app.get('/api/games/:id/map', (req, res) => {
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
// turn advance — the round is only committed (in one turn) by "Jump forward".
app.post('/api/games/:id/action', (req, res) => {
+87 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map';
import { nationColor } from '../shared/protocol';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
const state: GameState = {
@@ -33,6 +33,9 @@ const state: GameState = {
version: 1,
};
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
const regions: RegionMap = buildRegionMap(state);
describe('buildRegionMap', () => {
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state);
@@ -44,6 +47,89 @@ 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 (sub-national map). The click/hover handlers resolve the
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
* source's top-level `id` into a *numeric index* when it stores the source, so a
* feature the map hands back at interaction time has a number for `id`. The
* choropleth is now sub-national: a feature's id is a sub-region code
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
* client stamps into `properties.parent` (the RegionMap is keyed by that
* country code, never the sub-region id). This pins the exact data shape and
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
* `regionId`, which is not a RegionMap key) do not.
*/
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => {
// The shape MapLibre returns for a sub-region feature after setData(): `id`
// is a number (repacked), and the stable identifiers live only in properties —
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
const repackedFeature = {
id: 0,
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
};
it('resolves the owner from properties.parent (the fix)', () => {
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
});
});
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
// 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'.
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
});
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
// reported "No owner" even for owned regions.
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
});
});
describe('mapFeatureOwner', () => {
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
expect(mapFeatureOwner(regions, null)).toBeUndefined();
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
expect(mapFeatureOwner(regions, {})).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
});
it('returns undefined for a parent country nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
});
});
describe('nationColor', () => {
it('is deterministic per id and distinct across different ids', () => {
expect(nationColor('uk')).toBe(nationColor('uk'));
+43
View File
@@ -56,6 +56,44 @@ export interface RegionOwner {
}
export type RegionMap = Record<string, RegionOwner>;
/**
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
* The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and
* the LLM stay at country granularity — see #16), so `key` is a country code such
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
* empty/missing key or an unowned country.
*
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
* which reads the feature's `properties.parent` — never the repacked top-level
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
* the sub-region id.
*/
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
if (!key) return undefined;
return regions[key];
}
/**
* Resolve the owning-nation record for a **rendered map feature** from the
* authoritative RegionMap. The choropleth renders sub-national regions, but the
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
* `regionOwner`. This is the single shared source of truth for that resolution,
* so the "use `parent`, never the sub-region `id`/repacked `feature.id`" rule
* lives in one tested place rather than in the React component.
*
* Returns `undefined` for a missing feature/parent or an unowned country.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { parent?: unknown } } | null | undefined,
): RegionOwner | undefined {
const parent = feature?.properties?.parent;
return regionOwner(regions, typeof parent === 'string' ? parent : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
export type SseMessage =
| { type: 'state'; state: GameState }
@@ -124,6 +162,11 @@ export interface GameSummary {
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
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
+164 -62
View File
@@ -7,8 +7,6 @@ import {
ListTree,
Loader2,
Map as MapIcon,
Minus,
Plus,
Radar,
} from 'lucide-react';
import { Toaster } from './components/ui/sonner';
@@ -18,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect';
import WorldMap, { type MapApi } from './components/WorldMap';
import WorldMap from './components/WorldMap';
import FloatingWindow from './components/FloatingWindow';
import MapLegend from './components/MapLegend';
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_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> {
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
// 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 [advisors, setAdvisors] = useState<string[]>([]);
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
// list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
@@ -273,6 +309,12 @@ export default function App() {
useSse(game?.id ?? null, {
onState: 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) => {
@@ -374,14 +416,21 @@ export default function App() {
}
}, [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(
async (prompt?: string) => {
if (!game) return;
setAdvisorBusy(true);
try {
const { suggestions } = await api.advisor(game.id, prompt);
setAdvisors(suggestions);
} catch (e) {
toastError(e);
} finally {
setAdvisorBusy(false);
}
},
[game],
@@ -422,6 +471,29 @@ export default function App() {
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.
latestRef.current = { game, goHome, loadGame };
@@ -434,6 +506,7 @@ export default function App() {
game={game}
regions={regions}
busy={busy}
advisorBusy={advisorBusy}
advisors={advisors}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
@@ -465,6 +538,7 @@ export default function App() {
savedGames={savedGames}
activeGameId={activeGameId}
onResume={resumeGame}
onDelete={runDeleteGame}
/>
</>
);
@@ -473,7 +547,10 @@ export default function App() {
interface GameScreenProps {
game: GameState;
regions: RegionMap;
/** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */
busy: boolean;
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
advisorBusy: boolean;
advisors: string[];
onAddAction: (a: 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
* map is a fixed, full-viewport background (z-0); over it float draggable,
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
* live status, "Focus map" collapse-all) at the top, the nation dossier
* (top-left), the event feed (right), the "Your move" command deck
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom
* control (bottom-right). Each 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.
* map is a fixed, full-viewport background (z-0) that the user drives directly
* (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
* a slim **pinned** header bar (fixed, non-movable, centered at the top a few
* px down; scenario, turn + date, player nation, live status, "Focus map"
* collapse-all); the nation dossier, the event feed, and the "Your move"
* command deck — each free-floating by default (at the same first-run positions
* as before) but **dockable** into a full-height left or right pane, the choice
* 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({
game,
regions,
busy,
advisorBusy,
advisors,
onAddAction,
onAddDiplomacy,
@@ -508,6 +589,17 @@ function GameScreen({
}: GameScreenProps) {
// Which nation's dossier to show. Defaults to the player; remounts per game.
const [viewingId, setViewingId] = useState(game.playerNationId);
// When the dossier target was set by a map click, remember which sub-region
// (its own name + parent country) was clicked, for a small label in the
// dossier. Cleared when the user picks a nation via the manual Inspect select.
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
const focusNation = useCallback(
(id: string, info?: { parent?: string; name?: string }) => {
setViewingId(id);
setSelectedRegion(info ?? null);
},
[],
);
const player = game.nations[game.playerNationId];
const viewed = game.nations[viewingId] ?? player;
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
@@ -534,11 +626,23 @@ function GameScreen({
});
}, []);
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control.
const mapApiRef = useRef<MapApi | null>(null);
const handleMapReady = useCallback((api: MapApi | null) => {
mapApiRef.current = api;
// Dock state for the three dockable panels (dossier / feed / action),
// persisted per id (survives reloads). The default is 'float' — the current
// first-run positions — so nothing visually changes until the user docks
// 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 (
<div className="min-h-screen">
@@ -548,14 +652,15 @@ function GameScreen({
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
onFocusNation={setViewingId}
onMapReady={handleMapReady}
onFocusNation={focusNation}
/>
{/* 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
id="header"
anchor="top-center"
dataTestId="top-bar"
pinned
collapsible={false}
title={
<div className="flex min-w-0 items-center gap-3">
@@ -602,17 +707,24 @@ function GameScreen({
{player?.name ?? game.playerNationId}
</span>
</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
data-testid="status-indicator"
className={cn(
'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-border bg-muted/40 text-muted-foreground',
)}
>
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
{busy ? 'Simulating…' : 'Idle'}
{advisorBusy || busy ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CircleDot className="size-3" />
)}
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
</span>
<Button
variant="secondary"
@@ -628,13 +740,16 @@ function GameScreen({
}
/>
{/* Nation dossier, top-left. */}
{/* Nation dossier, top-left by default; dockable into a left/right pane. */}
<FloatingWindow
id="dossier"
dataTestId="panel-dossier"
anchor="top-left"
width={300}
title="Dossier"
titleIcon={<BookOpen className="size-4" />}
dock={dockOf('dossier')}
onDockChange={(m) => setDockFor('dossier', m)}
collapsed={!!collapsed.dossier}
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
bodyClassName="max-h-[56vh] overflow-y-auto"
@@ -643,7 +758,14 @@ function GameScreen({
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Inspect
</div>
<Select value={viewingId} onValueChange={setViewingId}>
<Select
value={viewingId}
onValueChange={(id) => {
// A manual pick supersedes a map-click selection: clear the label.
setViewingId(id);
setSelectedRegion(null);
}}
>
<SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue />
</SelectTrigger>
@@ -655,13 +777,20 @@ function GameScreen({
))}
</SelectContent>
</Select>
{selectedRegion && (
<div className="mt-1.5 truncate text-[11px] text-muted-foreground" data-testid="selected-region">
Selected: {selectedRegion.name ?? selectedRegion.parent ?? 'region'}
{selectedRegion.parent ? ` (${selectedRegion.parent})` : ''}
</div>
)}
</div>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
</FloatingWindow>
{/* Event feed, right side. */}
{/* Event feed, right side by default; dockable into a left/right pane. */}
<FloatingWindow
id="feed"
dataTestId="panel-feed"
anchor="right"
width={330}
title="Event feed"
@@ -671,6 +800,8 @@ function GameScreen({
{game.events.length}
</Badge>
}
dock={dockOf('feed')}
onDockChange={(m) => setDockFor('feed', m)}
collapsed={!!collapsed.feed}
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
bodyClassName="h-[52vh]"
@@ -678,9 +809,10 @@ function GameScreen({
<EventFeed events={game.events} nations={game.nations} inWindow />
</FloatingWindow>
{/* Command deck ("Your move"), bottom-center — queue items, then commit. */}
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
<FloatingWindow
id="action"
dataTestId="panel-action"
anchor="bottom-center"
width={370}
title="Your move"
@@ -692,6 +824,8 @@ function GameScreen({
</Badge>
) : undefined
}
dock={dockOf('action')}
onDockChange={(m) => setDockFor('action', m)}
collapsed={!!collapsed.action}
onCollapsedChange={(c) => setCollapsedFor('action', c)}
bodyClassName="max-h-[72vh] overflow-y-auto"
@@ -699,6 +833,7 @@ function GameScreen({
<ActionPanel
game={game}
busy={busy}
advisorBusy={advisorBusy}
advisors={advisors}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}
@@ -721,39 +856,6 @@ function GameScreen({
>
<MapLegend regions={regions} />
</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>
);
}
+22 -2
View File
@@ -19,8 +19,14 @@ import type { GameState, RoundItem } from '@shared/types';
interface Props {
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;
/**
* 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. */
advisors: string[];
/** 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({
game,
busy,
advisorBusy,
advisors,
onAddAction,
onAddDiplomacy,
@@ -194,10 +201,23 @@ export default function ActionPanel({
variant="secondary"
size="sm"
onClick={() => onAdvisor(prompt.trim() || undefined)}
disabled={advisorBusy}
data-testid="ask-advisor"
>
Ask advisor
{advisorBusy && <Loader2 className="size-3.5 animate-spin" />}
{advisorBusy ? 'Thinking…' : 'Ask advisor'}
</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 && (
<ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => (
+157 -35
View File
@@ -6,7 +6,7 @@ import {
type CSSProperties,
type ReactNode,
} from 'react';
import { Minus, Plus } from 'lucide-react';
import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
/** 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-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.';
// 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.
const Z_BASE = 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 zTopId: string | null = null;
/** 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 {
/** Stable id — used for the localStorage key and as a data-attribute. */
id: string;
/** First-run placement. The last dragged position (persisted) takes over. */
anchor: FloatAnchor;
/** Optional `data-testid` on the window root, for e2e targeting. */
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. */
width?: number;
/** Extra px nudged away from the anchor position (e.g. clear the map attribution). */
@@ -128,7 +159,7 @@ export interface FloatingWindowProps {
title?: ReactNode;
/** Optional icon before the title. */
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;
/** Render the collapse toggle (default true). */
collapsible?: boolean;
@@ -143,16 +174,27 @@ export interface FloatingWindowProps {
}
/**
* A draggable, collapsible, position-persisted window that floats over the
* full-screen map. Dragging is limited to the title bar (via pointer capture)
* so inputs and buttons in the body keep working; the window is clamped so it
* can never be dragged fully off-screen, its position is remembered in
* localStorage, clicking any part of it raises it above siblings, and the
* collapse toggle shrinks it to just its title bar.
* A window that floats over the full-screen map. Three mutually-exclusive
* modes, set via props (absent = free-floating):
* • **free-floating** (default) — draggable by the title bar (pointer
* capture), clamped on-screen, position persisted in localStorage, raised
* above siblings on click, collapsible.
* • **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({
id,
anchor,
dataTestId,
anchor = 'top-center',
pinned = false,
dock,
onDockChange,
width,
offset,
title,
@@ -165,6 +207,12 @@ export default function FloatingWindow({
bodyClassName,
children,
}: 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 sizeRef = useRef<{ w: number; h: number } | null>(null);
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
@@ -177,9 +225,11 @@ export default function FloatingWindow({
setPosState(p);
};
// First paint: measure, then place (restored position or anchor) and clamp.
// Runs before paint (useLayoutEffect), so there is no visible flash.
// First paint (free-floating only): measure, then place (restored position
// 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(() => {
if (!isFloating) return;
if (posRef.current) return;
const el = rootRef.current;
if (!el) return;
@@ -195,11 +245,12 @@ export default function FloatingWindow({
);
posRef.current = p;
setPosState(p);
}, [id, anchor]);
}, [id, anchor, offset, isFloating]);
// 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(() => {
if (!isFloating) return;
const el = rootRef.current;
const p = posRef.current;
if (!el || !p) return;
@@ -208,10 +259,11 @@ export default function FloatingWindow({
sizeRef.current = { w: rect.width, h: rect.height };
const c = clampPos(p.x, p.y, rect.width, rect.height);
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(() => {
if (!isFloating) return;
const onResize = () => {
const p = posRef.current;
const s = sizeRef.current;
@@ -221,7 +273,7 @@ export default function FloatingWindow({
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
}, [isFloating]);
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
@@ -258,18 +310,44 @@ export default function FloatingWindow({
if (p) savePos(id, p.x, p.y);
};
const style: CSSProperties = {
zIndex: z,
...(pos ? { left: pos.x, top: pos.y } : {}),
};
// Root placement: pinned → centered top bar; docked → full-height edge
// column; free-floating → last/initial pixel position (hidden until measured).
const rootStyle: CSSProperties = isPinned
? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
: isDocked
? {
zIndex: z,
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 (
<div
ref={rootRef}
data-float-window={id}
style={style}
className={cn('fixed', !pos && 'pointer-events-none opacity-0')}
data-testid={dataTestId}
style={rootStyle}
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
onPointerDown={() => {
if (isPinned) return;
const nz = nextZ(id);
if (nz > 0) setZ(nz);
}}
@@ -277,21 +355,24 @@ export default function FloatingWindow({
<div
className={cn(
'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,
)}
style={
width != null
? { width, maxWidth: '96vw' }
: { width: 'fit-content', maxWidth: '96vw' }
isDocked
? { width: width ?? 'fit-content', maxWidth: '96vw' }
: width != null
? { width, maxWidth: '96vw' }
: { width: 'fit-content', maxWidth: '96vw' }
}
>
{title != null && (
<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"
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn(
'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',
isFloating && 'cursor-grab active:cursor-grabbing',
)}
{...titleBarHandlers}
>
{titleIcon && (
<span className="shrink-0 text-muted-foreground" aria-hidden>
@@ -302,6 +383,49 @@ export default function FloatingWindow({
{title}
</span>
{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 && (
<button
type="button"
@@ -315,9 +439,7 @@ export default function FloatingWindow({
)}
</div>
)}
{children != null && (
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
)}
{children != null && <div className={bodyClass}>{children}</div>}
</div>
</div>
);
+72 -6
View File
@@ -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 { Button } from './ui/button';
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 { GameSummary } from '@shared/protocol';
@@ -15,12 +24,14 @@ interface Props {
activeGameId?: string | null;
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
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
* "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({
presets,
@@ -29,8 +40,14 @@ export default function PresetPicker({
savedGames = [],
activeGameId,
onResume,
onDelete,
}: Props) {
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 (
<div className="mx-auto max-w-6xl px-6 py-10">
@@ -90,7 +107,7 @@ export default function PresetPicker({
))}
</div>
{/* Saved games: get back into any prior game. */}
{/* Saved games: get back into any prior game (or delete it). */}
{savedGames.length > 0 && (
<section className="mt-12" data-testid="saved-games">
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
@@ -100,13 +117,28 @@ export default function PresetPicker({
key={g.id}
className="flex flex-col"
data-testid="saved-game"
data-game-id={g.id}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{g.date}
</Badge>
<div className="flex shrink-0 items-center gap-1">
<Badge variant="secondary" className="text-xs">
{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>
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
</CardHeader>
@@ -125,6 +157,40 @@ export default function PresetPicker({
</div>
</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>
);
}
+162 -114
View File
@@ -8,11 +8,14 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// 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';
maplibregl.setWorkerUrl(workerUrl);
import type { RegionMap, RegionOwner } from '@shared/protocol';
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
// Minimal GeoJSON types (avoids a hard @types/geojson dependency).
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
// a top-level `id` of the form `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
interface GeoFeature {
type: 'Feature';
id?: string | number;
@@ -27,7 +30,7 @@ type GeoSource = maplibregl.GeoJSONSource;
// Inline basemap style — a visible dark-navy background plus a subtle
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
// is faintly visible. The country choropleth (added in `ensureLayers`) is drawn
// is faintly visible. The regional choropleth (added in `ensureLayers`) is drawn
// on top and is the real star.
//
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
@@ -61,7 +64,7 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
type: 'raster',
source: 'terrain',
paint: {
'raster-opacity': 0.5,
'raster-opacity': 0.15,
'raster-brightness-max': 0.8,
'raster-fade-duration': 200,
},
@@ -69,11 +72,11 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
],
};
const COUNTRIES_SOURCE = 'countries';
const REGIONS_SOURCE = 'regions';
const HOVER_SOURCE = 'hover';
const FLASH_SOURCE = 'flash';
const FILL_LAYER = 'countries-fill';
const LINE_LAYER = 'countries-line';
const FILL_LAYER = 'regions-fill';
const LINE_LAYER = 'regions-line';
const PLAYER_LINE = 'player-line';
const FLASH_LINE = 'flash-line';
const HOVER_FILL = 'hover-fill';
@@ -97,35 +100,68 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
}
/**
* Stamp the current ownership + colors onto a clone of the base geo. Each region
* 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
* layer). Unknown owners fall back to a faint neutral fill — never an error.
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
* Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation
* owns whole countries, so every sub-region of a country shares that country's
* owning nation's color — the map looks like the old country choropleth, but
* subdivided into finer cells. Each sub-region therefore carries:
*
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
* survives into the features MapLibre hands back on click/hover
* (the top-level `id` is repacked into a numeric index, #6);
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
* - `stroke` – a per-border alpha (owned brighter than unowned);
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
* player-highlight border).
*
* Crucially, the owner lookup keys the RegionMap by `properties.parent` (the
* country ISO_A3), **never** by the sub-region id — the RegionMap has no
* `FRA-03` entries, only `FRA`.
*/
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
f.properties = f.properties ?? {};
const r = f.id ? regions[f.id as string] : undefined;
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
// survives into the rendered features (the top-level `id` gets repacked).
const subId = f.id == null ? '' : String(f.id);
f.properties.regionId = subId;
// Ownership lives at the parent country: resolve `properties.parent`
// (the ISO_A3) against the RegionMap. Never the sub-region id.
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
f.properties.parent = parent;
const r = parent ? regions[parent] : undefined;
if (r) {
f.properties.fill = r.color;
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;
} else {
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.ownerId = '';
f.properties.isPlayer = 0;
}
}
return out;
}
/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): FeatureCollection {
const set = new Set(codes);
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
/**
* Build a FeatureCollection of just the sub-regions whose **parent** country is
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
* whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code
* must light up **all** of its sub-regions, not a single feature.
*/
function featuresByParents(geo: FeatureCollection, parents: Iterable<string>): FeatureCollection {
const set = new Set(parents);
const features = geo.features.filter((f) => {
const parent = f.properties?.parent;
return typeof parent === 'string' && set.has(parent);
});
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
}
@@ -137,72 +173,69 @@ function statRow(label: string, value?: number): string {
return `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">${Math.round(value)}</span></div>`;
}
/** Compact hover tooltip: country, its owner (color-chipped), and 1–2 key stats. */
function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
/**
* Compact hover tooltip: the sub-region's name, its parent country (ISO_A3), its
* owner (color-chipped), and 1–2 key stats. When the parent country has no
* owning nation, a minimal "No owner" card is shown instead.
*/
function hoverHtml(subRegionName: string, parentCode: 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>`;
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || parentCode || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
const sub = parentCode ? `<div class="hm-popup-sub">${esc(parentCode)}</div>` : '';
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
${sub}
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
<div class="hm-stats">${stats}</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 {
regions: RegionMap;
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>;
/** The active scenario id, for a sensible initial camera. */
presetId: 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`
* on unmount), so the floating zoom control can drive it.
* Called with a region's owning-nation id when a sub-region is clicked, so the
* dossier switches to that nation. For a sub-region, the owner is the parent
* country's owning nation. The optional `info` carries the clicked sub-region's
* own id/name and its parent country's ISO_A3, for a small "which region" label
* in the dossier. 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, info?: { parent?: string; name?: string }) => void;
}
/**
* World basemap (MapLibre GL) + a country choropleth colored by the owning
* 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
* stable fills (the choropleth is the star), thin borders, a brighter/thicker
* border for the player, hover + click popups with
* owner + stats, a transient flash on territory changes, and a
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating
* zoom control can drive it (no MapLibre navigation controls are shown).
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
* the finer `regions.geo.json` dataset: each country is subdivided into
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
* lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
* nation owns whole countries, so all of a country's sub-regions share that
* country's owning-nation color. Rendered as a fixed, full-viewport background
* layer (z-0) behind the floating HUD windows: a dark-navy basemap with subtle
* terrain, per-nation stable fills (uniform per country, the choropleth is the
* star), thin sub-region borders, a brighter/thicker amber border for the
* player's countries, a hover tooltip (sub-region name + parent country + owner
* + key stats), a **click that switches the dossier to the sub-region's parent
* country's owning nation** (no popup — see #7), a transient flash when a
* country's owner changes (lighting up all its sub-regions), and a per-scenario
* initial camera.
*
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
* never from the sub-region id (not a RegionMap key). Unowned countries (no
* RegionMap entry for the parent) 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({
regions,
@@ -211,7 +244,6 @@ export default function WorldMap({
presetId,
className,
onFocusNation,
onMapReady,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -222,13 +254,11 @@ export default function WorldMap({
const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId);
const onFocusNationRef = useRef(onFocusNation);
const onMapReadyRef = useRef(onMapReady);
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
presetRef.current = presetId;
onFocusNationRef.current = onFocusNation;
onMapReadyRef.current = onMapReady;
// Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes).
@@ -257,27 +287,15 @@ export default function WorldMap({
// Purely for testability; has no effect on rendering or behavior.
(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 clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
let lastHoverId: string | null = null;
const loadGeo = (): Promise<FeatureCollection> =>
baseGeoRef.current
? Promise.resolve(baseGeoRef.current)
: fetch('/countries.geo.json')
: fetch('/regions.geo.json')
.then((r) => {
if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`);
if (!r.ok) throw new Error(`regions.geo.json → HTTP ${r.status}`);
return r.json();
})
.then((g: FeatureCollection) => {
@@ -288,12 +306,14 @@ export default function WorldMap({
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
// Transient flash: pulse a bright outline over the changed regions, then fade
// to zero over a few seconds and self-clean. Idempotent per call.
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
// parent-country ISO_A3 codes (a turn changes whole countries); each lights
// up all of that country's sub-regions.
function animateFlash(codes: string[]) {
const geo = baseGeoRef.current;
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
if (!geo || !src || cancelled) return;
src.setData(featuresByCodes(geo, codes));
src.setData(featuresByParents(geo, codes));
if (!map.getLayer(FLASH_LINE)) {
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
// hover highlight when that layer exists; otherwise just append.
@@ -326,24 +346,34 @@ export default function WorldMap({
}
flashApiRef.current = animateFlash;
// The sub-region a hovered/clicked feature belongs to is identified by its
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
// and its **owner** is resolved from the authoritative RegionMap via the
// feature's `properties.parent` (the parent country's ISO_A3) through
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
// index, #6) and never from the sub-region id (not a RegionMap key).
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const id = f.id == null ? null : String(f.id);
if (id && id !== lastHoverId) {
lastHoverId = id;
const p = f.properties as { name?: string; regionId?: string; parent?: string };
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
const parent = typeof p?.parent === 'string' ? p.parent : '';
if (subId && subId !== lastHoverId) {
lastHoverId = subId;
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 sub-region id, so
// match on it to recover the exact feature for the hover-highlight
// outline (the feature MapLibre hands us has a repacked numeric `id`).
const feat = geo?.features.find((x) => x.id != null && String(x.id) === subId);
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
);
}
const p = f.properties as { name?: string };
const region = id ? regionsRef.current[id] : undefined;
const region = mapFeatureOwner(regionsRef.current, f);
const nation = region ? nationsRef.current[region.ownerId] : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? id ?? '', region, nation))
.setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -355,15 +385,25 @@ export default function WorldMap({
map.getCanvas().style.cursor = '';
};
// #7: a click does NOT open a popup — it switches the dossier to the region's
// owning nation. For a sub-region, the owner is the **parent country's**
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
// RegionMap (never the repacked top-level `f.id`, #6, and never the
// sub-region id). A sub-region whose parent country has no owner leaves the
// current dossier target unchanged — never a broken dossier.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const id = f.id == null ? null : String(f.id);
const p = f.properties as { name?: string };
const region = id ? regionsRef.current[id] : undefined;
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);
const region = mapFeatureOwner(regionsRef.current, f);
if (!region?.ownerId) return;
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
// the dossier can show which region was selected.
const p = f.properties as { regionId?: string; parent?: string; name?: string };
const info = {
parent: typeof p?.parent === 'string' ? p.parent : undefined,
name: typeof p?.name === 'string' ? p.name : undefined,
};
onFocusNationRef.current?.(region.ownerId, info);
};
// Idempotent: (re)creates the sources + layers after any style (re)load and
@@ -371,16 +411,16 @@ export default function WorldMap({
// automatically, so no setWorkerUrl is needed.)
//
// The three choropleth sources are created up front (empty until the base
// geo arrives) so the layer structure always exists; `paintCountries` then
// stamps ownership + colors onto the countries source. This makes it immune
// geo arrives) so the layer structure always exists; `paintRegions` then
// stamps ownership + colors onto the regions source. This makes it immune
// to load-order races: if `ensureLayers` runs before the geo fetch
// resolves, the data is applied the moment the fetch lands; if the geo
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
// regions effect re-paints whenever ownership changes.
const ensureSources = () => {
if (!map.getSource(COUNTRIES_SOURCE)) {
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() });
if (!map.getSource(REGIONS_SOURCE)) {
map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() });
}
if (!map.getSource(HOVER_SOURCE)) {
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
@@ -390,10 +430,10 @@ export default function WorldMap({
}
};
// Stamp ownership + colors onto the countries source (no-op until geo loads).
const paintCountries = () => {
// Stamp ownership + colors onto the regions source (no-op until geo loads).
const paintRegions = () => {
const geo = baseGeoRef.current;
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
};
@@ -402,32 +442,39 @@ export default function WorldMap({
if (cancelled) return;
ensureSources();
if (baseGeoRef.current) {
paintCountries();
paintRegions();
} else {
try {
await loadGeo();
} catch (err) {
console.error('failed to load countries geojson', err);
console.error('failed to load regions geojson', err);
}
if (cancelled) return;
paintCountries();
paintRegions();
}
if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain.
// Every sub-region carries its parent country's owner color, so a whole
// country reads as one uniform fill — same as the old country map, but
// subdivided.
map.addLayer({
id: FILL_LAYER,
type: 'fill',
source: COUNTRIES_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
source: REGIONS_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
});
}
if (!map.getLayer(LINE_LAYER)) {
// Thin internal borders: every sub-region outline at a light weight so
// the subdivision reads within each country's uniform fill. The per-
// feature `stroke` alpha keeps owned borders a touch brighter than
// unowned ones (the v0.10 treatment, thinned for the finer grid).
map.addLayer({
id: LINE_LAYER,
type: 'line',
source: COUNTRIES_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
source: REGIONS_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.75, 'line-opacity': 0.95 },
});
}
// NOTE: layers are added in bottom→top order (each appends on top). The
@@ -438,7 +485,7 @@ export default function WorldMap({
map.addLayer({
id: PLAYER_LINE,
type: 'line',
source: COUNTRIES_SOURCE,
source: REGIONS_SOURCE,
filter: ['==', ['get', 'isPlayer'], 1],
paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 },
});
@@ -482,7 +529,6 @@ export default function WorldMap({
cancelled = true;
cancelAnimationFrame(flashRaf);
flashApiRef.current = null;
onMapReadyRef.current?.(null);
map.remove();
mapRef.current = null;
if (containerRef.current) {
@@ -491,8 +537,10 @@ export default function WorldMap({
};
}, []);
// Re-color the choropleth whenever ownership changes, and flash any regions
// whose owner changed since the last update (transient, self-cleaning).
// Re-color the choropleth whenever ownership changes, and flash any country
// (all of its sub-regions) whose owner changed since the last update (a
// transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap;
// `animateFlash` expands each changed country to every one of its sub-regions.
useEffect(() => {
const map = mapRef.current;
const geo = baseGeoRef.current;
@@ -503,7 +551,7 @@ export default function WorldMap({
if (Object.keys(cur).length > 0) primedRef.current = true;
if (map && geo) {
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
}
+4
View File
@@ -7,6 +7,7 @@ import type {
AddDiplomacyResponse,
AdvisorResponse,
CreateGameResponse,
DeleteGameResponse,
GameSummary,
RemoveQueueItemResponse,
TurnResponse,
@@ -33,6 +34,9 @@ export const api = {
body: JSON.stringify({ presetId, playerNationId }),
}),
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
// home screen's "Saved games" section / resume affordances).
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
+17
View File
@@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol';
interface SseHandlers {
onState?: (state: GameState) => 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 */
}
};
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('turn', onTurn);
es.addEventListener('error', onError);
return () => es.close();
}, [id]);
}
+1 -1
View File
@@ -20,5 +20,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "server", "shared", "e2e", "vite.config.ts"]
"include": ["src", "server", "shared", "e2e", "scripts", "vite.config.ts"]
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
},
test: {
environment: 'node',
include: ['server/**/*.test.ts'],
include: ['server/**/*.test.ts', 'scripts/**/*.test.ts'],
testTimeout: 20000,
hookTimeout: 20000,
},