Compare commits

...
Author SHA1 Message Date
kamilkrzywda fdf6eb236a feat: sub-region-level territory — ownership, LLM and map at region granularity (v0.19.0)
Move territory from whole-country (ISO_A3) to individual sub-region id
granularity (PR 3 of #16):

- new server/regions.ts (turf-free): loadRegions (boot), expandTerritory
  (game creation), migrateTerritoryToRegions (legacy games on db.load),
  regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback
- game creation expands each preset nation's ISO_A3 list to its sub-region
  ids; pre-v0.19 saved games migrate transparently on read (idempotent)
- LLM contract: territoryChanges gain/lose sub-region ids (one region at a
  time; gain moves out of the prior owner; lost-without-gainer = unowned);
  user prompt carries a top-level regions reference table
  ({id: 'name (parent)'} over the state's territory ids)
- WorldMap: choropleth fill per sub-region (countries can split between
  nations), ownership via properties.regionId through the shared
  mapFeatureOwner, flash lights exactly the changed features
  (featuresByRegionIds)
- deterministic stub: one region-level move per non-empty round (player
  annexes the lexicographically-first region of the first other nation)
- tests: new server/regions.test.ts (real geo + fallback + seams);
  map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green

Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k
tokens — far under the 80k frontier limit, so the full regions table is
always sent (no frontier-limited mode needed).

Part of #16
2026-10-13 22:07:18 +00:00
kamilkrzywda 9c29779572 Merge pull request 'Region hover/selected shows current owner, not stale parent-country code (v0.18.0)' (#33) from fix/region-display-owner into master
CI / build + unit + e2e (push) Successful in 2m32s
2026-10-13 21:15:45 +02:00
kamilkrzywda 3cd8dcecfa Stop printing the static parent-country code in the region hover tooltip and dossier label (v0.18.0)
CI / build + unit + e2e (pull_request) Successful in 2m34s
2026-10-13 18:58:43 +00:00
kamilkrzywda acb4f0800f Merge pull request 'Fill no-one's-land gaps between merged sub-regions (v0.17.0)' (#32) from feat/fill-gaps into master
CI / build + unit + e2e (push) Successful in 1m39s
2026-10-13 17:48:17 +02:00
kamilkrzywda b1081eefde Fill no-one's-land gaps between merged sub-regions (v0.17.0)
CI / build + unit + e2e (pull_request) Successful in 2m19s
Part of #16
2026-10-13 15:18:31 +00:00
kamilkrzywda e92bf47d7d Merge pull request 'Smooth jagged sub-national region boundaries (v0.16.0)' (#30) from feat/smooth-regions into master
CI / build + unit + e2e (push) Successful in 2m21s
2026-10-13 11:53:58 +02:00
kamilkrzywda f1cc4fbc10 Smooth jagged sub-national region boundaries (v0.16.0)
CI / build + unit + e2e (pull_request) Successful in 2m18s
Part of #16
2026-10-13 09:42:44 +00:00
kamilkrzywda 97702940cd Merge pull request 'Clean up holes/gaps in sub-national regions (v0.15.0)' (#29) from feat/cleanup-regions into master
CI / build + unit + e2e (push) Successful in 2m21s
2026-10-12 16:00:44 +02:00
kamilkrzywda 8eedc37e05 Clean up holes/gaps in sub-national regions (v0.15.0)
CI / build + unit + e2e (pull_request) Successful in 2m15s
Part of #16
2026-10-12 13:51:47 +00:00
kamilkrzywda 69782aad69 Merge pull request 'Coarsen sub-national regions to a ~100km floor (v0.14.0)' (#28) from feat/coarsen-regions into master
CI / build + unit + e2e (push) Successful in 2m19s
2026-10-12 11:59:39 +02:00
kamilkrzywda 7bf11fa5b9 Coarsen sub-national regions to ~100km floor (v0.14.0)
CI / build + unit + e2e (pull_request) Successful in 1m47s
Part of #16
2026-10-12 09:52:52 +00:00
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
kamilkrzywda cfcdd8b8a8 docs: clarify Gitea issue convention (reference filed issues; no redundant changelog issue)
CI / build + unit + e2e (pull_request) Successful in 1m57s
2026-10-11 16:15:00 +00:00
kamilkrzywda ef27b59762 0.4.0 — fix queue-add race + per-item remove button (#10, #11) 2026-10-11 16:09:29 +00:00
kamilkrzywda 3287bcaca4 Merge pull request 'v0.3.0 — URL deep-linking (game id in URL, multi-tab)' (#13) from url-deep-link into master
CI / build + unit + e2e (push) Successful in 1m53s
2026-10-11 17:39:18 +02:00
kamilkrzywda f622cb676a url deep-link: game id in #/game/<id>, multi-tab + bad-link fallback (v0.3.0)
CI / build + unit + e2e (pull_request) Successful in 1m52s
2026-10-11 15:32:51 +00:00
kamilkrzywda 502fbd4874 Merge pull request 'v0.2.0 — LLM reliability + batched-round turn model' (#9) from reliability-batched-round into master
CI / build + unit + e2e (push) Successful in 1m52s
2026-10-11 16:46:01 +02:00
kamilkrzywda 9bce14254d batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s
2026-10-11 14:36:35 +00:00
kamilkrzywda 3297658c81 reliability: low reasoning_effort, retry-with-backoff, LLM call logging
- send top-level reasoning_effort: "low" (LLM_REASONING_EFFORT) to cut the qwen3.8-27b CoT ~85%, so content is no longer truncated to null -> the ~50% 502s
- new env: LLM_MAX_TOKENS (default 4000, per-call opts.maxTokens still overrides), LLM_REASONING_EFFORT (default "low", off/empty omits the field)
- chat() retries up to 3 attempts: timeout/abort, 5xx, network errors (ECONNRESET/ECONNREFUSED/socket closed), null/empty content; backoff 2s->3s (2000*1.5^(n-1), +/-20% jitter); 4xx throws immediately
- logging: '[llm] chat ok' {ms, finishReason} on success; '[llm] chat failed' {attempts, lastStatus, message, totalMs} on final failure
- tests: 12 in llm.test.ts (retry success/failure, no 4xx retry, null-content retry, logging spies); AGENTS.md env table + gotchas updated
2026-10-11 13:46:18 +00:00
kamilkrzywda 612de2fc40 Merge pull request 'Add Gitea Actions CI + switch to PR-based workflow' (#1) from ci-pipeline into master
CI / build + unit + e2e (push) Successful in 1m59s
2026-10-11 10:28:16 +02:00
37 changed files with 8102 additions and 598 deletions
+117 -34
View File
@@ -6,19 +6,20 @@ Guidance for AI agents (and humans) working in this repo.
A self-contained, AI-driven **alternate-history simulator** in the style of _Pax Historia_: A self-contained, AI-driven **alternate-history simulator** in the style of _Pax Historia_:
the LLM is the world engine. You pick a scenario (one of 5 seed presets) and a nation to lead, the LLM is the world engine. You pick a scenario (one of 5 seed presets) and a nation to lead,
then take actions / send diplomatic messages / ask an advisor; each move is sent to the model, then queue actions and diplomatic messages into a pending round; a single "Jump forward" sends
which returns a structured `TurnResult` (events + stat/relation/territory deltas) that the engine the whole batch to the model, which returns a structured `TurnResult` (events + stat/relation/territory
validates, clamps, and applies to the world state. A MapLibre choropleth renders territorial deltas) that the engine validates, clamps, and applies to the world state. A MapLibre choropleth
ownership; the whole thing persists to SQLite and streams over SSE on a single port. renders territorial ownership; the whole thing persists to SQLite and streams over SSE on a single port.
## Change process (mandatory) ## Change process (mandatory)
1. **Small, incremental, well-tested changes** — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel). 1. **Small, incremental, well-tested changes** — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel).
2. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea (`https://git.kkhost.pl`, repo `OpenCode/History`). 2. **Gitea issue + `Fixes #N` in the PR** — the PR references (and, via `Fixes #N`, closes) the Gitea issue(s) the work addresses (`https://git.kkhost.pl`, repo `OpenCode/History`). If the user has already filed issue(s) for the change, reference those directly in the PR — no separate changelog issue is created; only file a fresh issue when the work has none pre-filed (e.g. a fix originating purely from chat feedback). `CHANGELOG.md` is always the durable changelog of what shipped.
3. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed). 3. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea; reference the issue in the PR description.
4. **Version bump** — the version is updated in `package.json`. 4. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed).
5. **CI must pass** — branch protection on `master` requires the CI status check to be green before merge. Merge via auto-merge (`POST /repos/OpenCode/History/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`) so the PR merges by itself once CI is green. **CI never deploys to production.** 5. **Version bump** — the version is updated in `package.json`.
6. **Tag + manual deploy after merge** — once merged, tag the version on `master` (e.g. `v0.1.0`). Production (`https://history.dev.kkhost.pl`) is updated manually afterwards on the host: `docker compose up -d --build`, then verify the site. 6. **CI must pass** — branch protection on `master` requires the CI status check to be green before merge. Merge via auto-merge (`POST /repos/OpenCode/History/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`) so the PR merges by itself once CI is green. **CI never deploys to production.**
7. **Tag + manual deploy after merge** — once merged, tag the version on `master` (e.g. `v0.2.0`). Production (`https://history.dev.kkhost.pl`) is updated manually afterwards on the host: `docker compose up -d --build`, then verify the site.
## Stack ## Stack
@@ -26,8 +27,10 @@ ownership; the whole thing persists to SQLite and streams over SSE on a single p
- Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui - 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` - 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) - Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth - Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
from `public/countries.geo.json` (`id` = ISO_A3) 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 - 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 **injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport
(`LLM_STUB=1`) is used by all tests/e2e. (`LLM_STUB=1`) is used by all tests/e2e.
@@ -47,21 +50,22 @@ history/
├── index.html ├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest) ├── presets/ # 5 scenario JSON files + index.json (manifest)
├── public/ ├── 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 ├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, Preset, …) │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + client↔server messages + pure helpers │ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
├── server/ ├── server/
│ ├── index.ts # Express: /api/*, /sse/:id, static+SPA; reads PORT/LLM_*/DATA_DIR │ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / advisor │ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
│ ├── llm.ts # LlmClient (injectable transport) + env config │ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER │ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color) │ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
│ ├── presets.ts # load preset JSON files from disk │ ├── presets.ts # load preset JSON files from disk
│ ├── db.ts # SQLite open/save/load │ ├── db.ts # SQLite open/save/load
│ └── *.test.ts # unit tests (vitest) │ └── *.test.ts # unit tests (vitest)
└── src/ └── src/
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors state ├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
├── main.tsx # React root + ThemeProvider(dark) ├── main.tsx # React root + ThemeProvider(dark)
├── index.css # Tailwind v4 + dark "command-center" theme ├── index.css # Tailwind v4 + dark "command-center" theme
├── lib/ ├── lib/
@@ -71,10 +75,11 @@ history/
│ └── utils.ts # cn() │ └── utils.ts # cn()
└── components/ └── components/
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens ├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight ├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + "Jump forward" ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first) ├── EventFeed.tsx # scrolling event log (newest first)
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
├── StatBar.tsx ├── StatBar.tsx
└── ui/ # 14 shadcn/ui primitives └── ui/ # 14 shadcn/ui primitives
``` ```
@@ -86,7 +91,7 @@ history/
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently | | `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` | | `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 | | `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests in `server/` (19 tests) | | `npm test` | vitest unit tests in `server/` (39 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs | | `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config | | `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) | | `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
@@ -97,12 +102,40 @@ history/
## Conventions ## Conventions
- **The server is authoritative.** All world state lives in SQLite (`server/db.ts`). The client - **The server is authoritative.** All world state lives in SQLite (`server/db.ts`). The client
renders state and sends intents (`/api/games/:id/turn|jump|advisor`); it never computes outcomes. renders state and sends intents (`/api/games/:id/action|diplomacy|jump|advisor`); it never computes
`shared/protocol.ts` is the single source of truth for the LLM `TurnResult` and the outcomes. `shared/protocol.ts` is the single source of truth for the LLM `TurnResult` and the
client↔server message shapes — change it there first, then update both sides. client↔server message shapes — change it there first, then update both sides.
- **No hardcoded origins.** The client uses same-origin relative paths (`/api/...`, `/sse/...`). - **No hardcoded origins.** The client uses same-origin relative paths (`/api/...`, `/sse/...`).
In dev, Vite proxies them to :4321; in prod/Traefik they're the same origin. So the app works In dev, Vite proxies them to :4321; in prod/Traefik they're the same origin. So the app works
unchanged behind the proxy — never bake in `http://host:port`. unchanged behind the proxy — never bake in `http://host:port`.
- **Hash-based routing (client-only).** The active game's id lives in the URL fragment: home is
`#/` (a bare page with no hash is also home) and an active game is `#/game/<id>`. This is a manual
hash sync in `App.tsx` — no react-router, no server route (every non-API GET just serves
`index.html`), so it is Traefik/static-safe. A deep link opens that exact game and two tabs can
run two different games (each has its own React state + `useSse(game.id)`). The URL hash takes
precedence over the localStorage active-game id, which remains the fallback for a bare `/` load;
a bad/missing id clears the hash to `#/` and falls back to home.
- **Batched round model.** Actions and diplomatic messages are queued in `game.pending` via
`POST /action` and `POST /diplomacy` (fast, no LLM call, no turn advance). A single
`POST /jump` ("Jump forward") commits the whole pending batch in one LLM turn: `engine.runTurn`
sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, and
advances the turn. An empty round (no items queued) is allowed and lets the world advance on its
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,
and legacy games without the field are treated as version 0. ALL state ingestion on the client —
SSE `state`/`turn` events, REST responses (add/remove/jump), and game loads — flows through the
single `applyState` in `App.tsx`: a *different* game id is always adopted, the *same* game only
when the incoming version is not older than the current one (compared inside the functional
`setGame` updater, so it can't act on a stale closure). This is what stops a late-arriving stale
SSE snapshot from clobbering the UI (see Gotchas, #10).
- **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first - **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first
balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries
once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic
@@ -110,11 +143,38 @@ history/
(model down) surfaces as an HTTP 502 to the client (shown as a toast). (model down) surfaces as an HTTP 502 to the client (shown as a toast).
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the - **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
server's `RegionMap` and any client coloring so the choropleth always agrees. server's `RegionMap` and any client coloring so the choropleth always agrees.
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically: the player - **Map click → dossier target (owner resolved from the RegionMap, via the parent country).**
nation's `military` is set to `(current + 5)` (clamped) each turn, one event is emitted Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
(action→military, chat→diplomatic, jump→political "The world moves on"), it allies the player with **parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records
the first other nation, and the date advances 2 days. Unit + e2e assertions rely on these exact which region was clicked for a small dossier label); the manual "Inspect" selector and a map click
values, so keep the stub deterministic. 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
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
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 - Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`. primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.
@@ -137,19 +197,41 @@ history/
| `LLM_API_BASE` | `https://ai.kkhost.pl/v1` | OpenAI-compatible **vLLM** base URL (no API key required) | | `LLM_API_BASE` | `https://ai.kkhost.pl/v1` | OpenAI-compatible **vLLM** base URL (no API key required) |
| `LLM_MODEL` | `qwen3.8-27b` | vLLM **reasoning** model — CoT in `reasoning`, final answer in `content` | | `LLM_MODEL` | `qwen3.8-27b` | vLLM **reasoning** model — CoT in `reasoning`, final answer in `content` |
| `LLM_TIMEOUT_MS` | `180000` | the 27B reasoning model can be slow | | `LLM_TIMEOUT_MS` | `180000` | the 27B reasoning model can be slow |
| `LLM_MAX_TOKENS` | `4000` | max **output** tokens per request; with `reasoning_effort:low` a realistic turn uses ~800–1100, so 4000 is ~4× headroom and stays under the 90s proxy wall |
| `LLM_REASONING_EFFORT` | `low` | sent as top-level `reasoning_effort`; cuts the model's CoT by ~85% (the 502 fix). Set to empty/`off` to not send the field at all |
| `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model needed) | | `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model needed) |
> **Reasoning model:** `qwen3.8-27b` emits its chain-of-thought in the response's `reasoning` > **Reasoning model:** `qwen3.8-27b` emits its chain-of-thought in the response's `reasoning`
> field and the final JSON answer in `content`. `server/llm.ts` therefore always sends a generous > field and the final JSON answer in `content`. `server/llm.ts` therefore sends top-level
> `max_tokens` (`DEFAULT_MAX_TOKENS = 4000`) so the model finishes reasoning before writing > `reasoning_effort: "low"` (`LLM_REASONING_EFFORT`) to cut the CoT by ~85% — small enough to
> `content` (a too-small budget returns a null `content`), and reads only `content`, ignoring > fit the `max_tokens` budget (`LLM_MAX_TOKENS`, default 4000) — and reads only `content`,
> `reasoning`. No auth header is sent — the endpoint requires no key. > ignoring `reasoning`. `chat()` retries transient failures (timeout/abort, 5xx, network
> errors, null/empty `content`) up to 3 attempts with 2s→3s backoff and logs every call
> (`[llm] chat ok` / `[llm] chat failed`). No auth header is sent — the endpoint requires no key.
| `PORT` | `4321` | single server port | | `PORT` | `4321` | single server port |
| `DATA_DIR` | `./data` | where `history.db` lives | | `DATA_DIR` | `./data` | where `history.db` lives |
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) | | `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
## Gotchas ## Gotchas
- **Stale SSE snapshots can arrive *after* a fresher REST response — the version guard is what
keeps the UI honest (#10).** The SSE `state` event sent on connect/resync is a snapshot taken at
the moment the request is handled, and the SSE stream and the REST responses ride different
connections (Vite proxy in dev, Traefik in prod) with no delivery-order guarantee. A snapshot
taken just before a mutation is saved (e.g. the on-connect resync racing a quick "Add action")
can reach the browser *after* that mutation's REST response. Pre-v0.4.0 the client applied any
incoming state unconditionally, so a just-queued item could vanish ("add one: nothing happened,
add another: both appears" — issue #10). Now every state carries a monotonic `version` and
`App.tsx`'s `applyState` drops same-game states whose version is older than the current one.
Don't add unguarded `setGame` calls or remove the version comparison.
- **502s were the reasoning model exhausting its output budget.** The original client sent only
`max_tokens: 4000`; `qwen3.8-27b`'s chain-of-thought alone often exceeded 4000 tokens, so
`content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns),
with no retry and no logging. Fixed by sending `reasoning_effort: "low"` (cuts the CoT ~85%),
retrying transient failures (timeout/abort, 5xx, network errors, null/empty `content`) up to
3 attempts with 2s→3s backoff, and logging every LLM call. A realistic turn now takes
~11–16s; the effective ceiling is the upstream OpenResty ~90s timeout, **not** our 180s
`LLM_TIMEOUT_MS`.
- **Live LLM is a runtime dependency.** The live model is `qwen3.8-27b` on - **Live LLM is a runtime dependency.** The live model is `qwen3.8-27b` on
`https://ai.kkhost.pl/v1` (a vLLM **reasoning** model, no API key). If that endpoint is down, `https://ai.kkhost.pl/v1` (a vLLM **reasoning** model, no API key). If that endpoint is down,
turns return 502 (and the advisor degrades to a canned list). Set `LLM_STUB=1` for a fully turns return 502 (and the advisor degrades to a canned list). Set `LLM_STUB=1` for a fully
@@ -165,5 +247,6 @@ history/
caught. caught.
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary - **`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. (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 - The `dist/` client is committed to the build output only; the `public/` geo files
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy). (`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).
+125
View File
@@ -2,6 +2,131 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## v0.19.0 — 2026-10-13 22:02
- **Sub-region-level territory (PR 3 of #16): ownership, the LLM, and the map all operate at individual sub-region granularity instead of whole-country.**
- `Nation.territory` is now a list of **sub-region ids** (`FR-57`, `DE-BB`, `US-AK`, … — the feature ids of `regions.geo.json`) rather than ISO_A3 country codes. At game creation (`POST /api/games`) each preset nation's ISO_A3 list is expanded to all its sub-region ids by the new `server/regions.ts` (a turf-free module that loads `public/`/`dist/regions.geo.json` once at boot into a region-id index + a country→regions map). Games saved before v0.19 are transparently migrated to region-level territory on read (`db.load` → `migrateTerritoryToRegions` — idempotent, and a no-op when the geo is not loaded).
- **The LLM contract follows the map:** a `TurnResult`'s `territoryChanges` now gain/lose sub-region ids, so a border can shift **one region at a time** — a gained region is moved out of whichever nation currently holds it, and a lost region with no matching gainer becomes unowned no-man's-land (the `applyTurnResult` splice logic was already format-agnostic and is unchanged). The user prompt carries a new top-level `regions` reference table — `{ id: "name (parent)" }` for every region id present in the world state — so the model can map an id it is considering to a real place; the system prompt's hard constraints now name sub-region ids (e.g. `FR-57`). **Prompt-size measurement (full `wwii-1939`: 7 nations, 411 regions): the complete user prompt is ≈ 24.7k chars / ≈ 6.2k tokens at start (≈ 26.4k / ≈ 6.6k with 8 recent events + a 2-item round) — far under the ~80k frontier limit, so the full `regions` table is always sent (no frontier-limited mode was needed).**
- **The choropleth is now truly per-region:** a nation's color fills exactly the sub-regions it owns, so a single country can be visibly split between nations, and unowned sub-regions render grey. Click/hover ownership resolves via the feature's `properties.regionId` (now the RegionMap key) through the shared `mapFeatureOwner` in `shared/protocol`, and the territory-change flash lights up exactly the regions whose owner changed (`featuresByRegionIds`, replacing the country-expanding `featuresByParents`).
- **Graceful degradation:** when the geo file is missing or unreadable the region maps stay empty and `hasRegionData()` is false, so expansion, migration, the prompt's `regions` table, and ownership resolution all fall back to the pre-v0.19 whole-country ISO_A3 behavior — the server still boots and the app still works (with a one-time console warning).
- **The deterministic stub stays deterministic** and now also makes exactly one region-level move per non-empty round: the player annexes the lexicographically-first sub-region of the first other nation (by key order) — empty rounds make no move — so unit tests and e2e assert the exact region that changes hands.
- **Tests:** new `server/regions.test.ts` (real-geo expansion/migration, the not-loaded fallback path, and the fixture seams); `server/map.test.ts` and `server/engine.test.ts` re-pinned to region-id fixtures, including the #6 regression guard (owner from `properties.regionId`, never the repacked numeric `feature.id`) and new `applyTurnResult` tests for prior-owner transfer, no-owner gain, and no-gainer lose.
- Part of #16
## v0.18.0 — 2026-10-13 18:54
- **Fixed a stale-owner display bug: the region hover tooltip and the dossier "Selected" label no longer print the static parent-country code (e.g. "FRA").** The GeoJSON `properties.parent` is a *static geographic-origin* field that never updates when a country is taken, yet it was rendered in the hover tooltip (as a sub-line and as a title fallback) and in the dossier's `Selected:` label (as a `(FRA)` suffix) — so a sub-region of a transferred country still displayed the old country's code next to the (correct) new owner. The tooltip now shows only the region name + the live owner chip (from the RegionMap) + key stats, and the dossier label shows only the region name. Ownership resolution still keys on `properties.parent` (unchanged) — only the display of it was removed.
## v0.17.0 — 2026-10-13 15:13
- **Filled the no-one's-land left between merged sub-regions (Part of #16).** The v0.14 coarsening's boolean dissolves left slivers of each country's landmass inside no region — measured at **≈ 3.90M km² globally** (the union of a country's regions covered only ~98% of its landmass). The new `scripts/fill-gaps.mjs` (`npm run fill:regions`) closes them: per country it computes the gap (`landmass − union(its regions)`, `@turf/difference`+`union`), drops slivers < **25 km²** (66,637 of them — sub-pixel on the map), and assigns each remaining piece to the **nearest region** (centroid distance) as an appended MultiPolygon part. **2,696,811 km²** of no-one's-land was filled into **1,575 regions** (+6,768 parts: 5,026 → 11,794), leaving **63,889 km²** residual (**98.4% reduction** — the rest is coastline digitization fuzz and 14 tiny border-band overlap artifacts, see below). The named "messy" regions absorb their surroundings: **SI-048 Kočevje (SVN) 16,692 → 19,816 km² (57 → 90 parts)**, **MK-36 Kavadarci (MKD) 21,297 → 25,190 km² (8 → 17)**, **AZ-AGC Ağcabədi (AZE) 18,657 → 32,065 km² (7 → 14)**; the biggest single fills are **AQ-X01~ (Antarctica) +195,351 km²**, **CA-NU (Nunavut) +143,447**, **AQ-X02~ +142,615**, **CL-MA (Magallanes) +81,806**, **US-AK (Alaska) +63,830**, **CA-NT (NWT) +46,333**, **GL-UO (Kommuneqarfiup) +41,971**, **RU-KAM (Kamchatka) +41,911**.
- **Correct, not just greedy: clipping, hole policy, dedup.** Each gap piece is clipped sequentially against (a) all *other countries'* regions, (b) its own country's existing regions, and (c) pieces already appended (bbox-touching) — so a piece straddling a border is split, and no piece can double-cover another region's area (**1,149,841 km² of foreign overhang removed** this way; 0 clip failures). **FR-57 Moselle (FRA) gains nothing on purpose**: its apparent gap was a cross-border phantom consumed by the neighbor clip — correct behavior. Holes follow a keep-or-drop policy: a gap hole whose centroid matches a **pre-existing hole** (within 10 km) or lies inside another country's landmass is kept, otherwise it's a phantom and dropped — so the **KZ-KZY Aral hole survives** (5,858 km² planar, the file's only hole; 5,875 km² of water kept open). A global dedup set guarantees each cleaned piece is appended at most once.
- **The dataset contract is untouched.** Still **1,796 regions** with identical `id` / `properties.name` / `properties.parent` and order — the fill only *appends* parts; all **177 countries** covered; the single KZ-KZY hole intact. **14 genuinely new overlap pairs** remain (13 same-country border bands + 1 cross CN-FJ×TW-KIN) — a known polychip artifact of filling right up to a shared boundary (the pre-fill file already carried 3,690 pre-existing pairs), and they are sub-pixel at map scale, so they're accepted. The file grows **2,289,528 → 3,399,806 bytes (2.29 → 3.40 MB)** — under the 5 MB guard, so no Douglas–Peucker pass is needed.
- **Deterministic + idempotent.** Fixed 25 km² threshold, deterministic ordering (centroid → area → id), 4-dp rounding (same convention as the other scripts). A fresh run on the pristine v0.16 file reproduces the shipped byte-for-byte (sha256 `87f44198…`), and re-running on the filled file is a no-op (byte-identical output).
- **Tests** — `scripts/regions.test.ts` (now 9 tests): the v0.16 fixed-point pin is updated to the v0.17 topology (1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole); a new test asserts the **only** hole in the file is KZ-KZY and sits in a realistic 5,000–7,000 km² band; a new **grid-sampled per-country coverage** test (25×25 grid per country, inline even-odd ray cast) asserts no significant no-one's-land remains — median 1.8% / p90 6.1% / max 26.4% (BRN, a coastline-denseness artifact, not a real gap), with thresholds at 5% / 15% / 40% and ≤3 countries above 20%. The planar sum band's upper bound widens 175% → **190%**: the fill only adds area *inside* each country, so it can't shrink the pre-existing admin-1 over-extension tail (Vanuatu 178.9%, Solomon Is. 133% — tiny island nations whose admin-1 polygons extend beyond their own country polygon), while the old under-fill tail is gone (minimum is now Brunei at 78.8%, was ~52%).
- **Type-check gotcha (why the test stays turf-free):** the project keeps `@types/geojson` **out of the tsc program** (tsconfig `types: ["vite/client", "node"]`). maplibre-gl's d.ts references the `GeoJSON` UMD global *without importing it*, so under `skipLibCheck` the reference degrades to `any` and `WorldMap.tsx`'s deliberately-minimal `GeoFeature` (with `geometry: unknown`) passes. The moment anything (e.g. a test importing `@turf/turf`) pulls `@types/geojson` into the program, those references typecheck for real and `WorldMap.tsx` fails with 7 errors. The v0.14–0.16 scripts are `.mjs` (never typechecked) for exactly this reason; the new test uses an inline ray cast instead of a geometry library, and `@turf/*` (a new devDependency, 137 packages including the meta `@turf/turf`) is only ever *run* by the `.mjs` scripts.
- Part of #16
## v0.16.0 — 2026-10-13 09:37
- **Smoothed the jagged sub-national region boundaries with Douglas–Peucker vertex-thinning (Part of #16).** The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization left some boundaries overly jagged — the worst being regions the dissolve split into many small zig-zag fragments, e.g. **SI-048 Kocevje (Slovenia, 57 parts)** and **MK-36 Kavadartsi (N. Macedonia, 8 parts)**. The new `scripts/smooth-regions.mjs` (`npm run smooth:regions`) runs `@turf/simplify` (Ramer–Douglas–Peucker, tolerance ~**50 m**) over **every ring of every polygon**, dropping the redundant "in-between" vertices of a shallow zig-zag while keeping the overall shape.
- **Vertex-thinning only — no topology change.** DP only removes points: it never moves a border, never merges/dissolves a part, and never creates or removes a ring. So the dataset contract is fully preserved — still **1,796 regions** with identical `id` / `properties.name` / `properties.parent`, all **177 countries** covered, the same **5,026 parts / 5,027 rings**, the single **KZ-KZY** hole, and all **83 islands**. The 18 degenerate 3-point "micro-dot" rings (Ragged Island, the Paracels, …) are kept verbatim (`@turf/simplify` requires ≥4 pts and would otherwise throw).
- **A gentle pass, reported honestly.** At 50 m the dataset is already fairly smooth (the v0.14 dissolve produced low-vertex, widely-spaced boundaries — median consecutive-vertex spacing ~18 km), so this is a light cleanup: **111,891 → 111,175 vertices (716 removed, 0.64%)** across **542 of 1,796** regions. **Kocelje 612 → 599** and **Kavadartsi 169 → 163** verts; both keep their exact part count (the "mess" there is fragmentation, which thinning — under the no-topology-change constraint — does not fix). The file shrinks **2,302,473 → 2,289,528 bytes (2.29 MB)**. Bump `TOLERANCE_M` to 100 / 200 m and re-run for a stronger pass (100 m → 974 removed; 200 m → 1,312 removed) — the pass stays idempotent either way.
- **The "messy" regions are really fragmentation artifacts, not fine jaggedness.** Ranking by **part density** (parts packed per unit area) rather than raw part count — which is dominated by legitimate *dispersed* archipelagos (Nunavut 213, Alaska 159, Antarctica 135, British Columbia 84) — surfaces the genuinely messy **coherent regions the dissolve left fragmented**: **SI-048 Kocevje (SVN, 57 parts in ~16,600 km²)**, **GR-H Stereá Elláda / Central Greece (GRC, 53 parts)**, **KR-46 South Jeolla (KOR, 28 parts)**, **SE-C Uppsala (SWE, 19 parts)**, **HR-13 Zadarska (HRV, 17 parts)**, plus the milder Balkan **MK-36 Kavadartsi (MKD, 8 parts)**. These real land regions (the Balkan ones in particular) ended up as many scattered fragments — the "similar shape problems".
- **Deterministic + idempotent.** One `@turf/simplify` pass at a fixed tolerance, then 4-dp rounding (the same convention as the other scripts). The output already sits at the pass's fixed point, so re-running on the smoothed file is byte-identical (sha256 `70a43541…`, verified across repeated runs); `public/regions.geo.json` and the built `dist/regions.geo.json` are sha256-identical.
- **Tests.** `scripts/regions.test.ts` gains an eighth test pinning the v0.16 fixed point: 1,796 regions, 5,026 parts, 5,027 rings, the single KZ-KZY hole, and exactly **111,175** vertices (8 tests total). All prior assertions (well-formed, unique ids, 177-country coverage, area floor, holes, coverage band) pass unchanged on the smoothed dataset.
- Part of #16
## v0.15.0 — 2026-10-12 13:49
- **Filled the small holes left by the v0.14 coarsening (Part of #16).** The coarsened `public/regions.geo.json` still carried **2,245 tiny enclosed holes** (all < 500 km² in practice) — unfilled patches left by the boolean dissolves and Natural Earth's own digitization, which showed up as "holes in regions". The new `scripts/cleanup-regions.mjs` (`npm run cleanup:regions`) drops every inner ring smaller than the 1,000 km² threshold so each region becomes solid: across **498 features, 56,484 km²** of artifact area was filled, removing the visible unfilled dots.
- **Real water is kept, not erased.** Any enclosed hole ≥ 1,000 km² survives as a hole. In this dataset the genuinely large lakes (the Great Lakes, the Caspian, the Aral, Lake Superior) had already been dissolved by the v0.14 coarsening, so none remain to preserve; the only surviving hole is a single medium one — **KZ-KZY (Kyzylorda, Kazakhstan) at 5,858 km²**, a real water body in the Aral basin — which is kept. The 1,000 km² threshold sits comfortably between the tiny artifacts (all < 500) and any genuine lake, so it fills exactly the artifacts and nothing else.
- **The dataset contract is untouched.** Still **1,796 regions** with identical `id`s / `properties.name` / `properties.parent` (0 mismatches), all **177 countries** still covered, the ~100 km floor and the island exemption intact — only inner rings were dropped, never a feature or its identity. The file shrinks 2,431 → **2,249 KB (2.20 MB)** (fewer inner rings).
- **Deterministic + idempotent.** Fixed threshold, deterministic planar geometry, 4-dp rounding; re-running on the cleaned file rewrites a byte-identical file (sha256-verified). Bridging very-close MultiPolygon parts is available behind an opt-in `--bridge` flag but is **off by default**, because the union it performs can itself mint new medium-sized holes — counter to the fill-holes goal — so the shipped run is the clean hole-fill only.
- **Tests.** `scripts/regions.test.ts` gains a seventh test asserting that no enclosed hole below the cleanup threshold remains and that only a handful of real water bodies survive (57 tests total). `public/regions.geo.json` and the built `dist/regions.geo.json` are sha256-identical.
- Part of #16.
## v0.14.0 — 2026-10-12 09:51
- **Coarsened the sub-national choropleth dataset to a ~100 km floor (Part of #16).** `public/regions.geo.json` shrinks from 4,138 real administrative regions to **1,796** (2,342 merges): every region smaller than ~100×100 km (10,000 km²) that has a same-country neighbor within 0.2° (≈22 km) of its bounding box is **dissolved into its nearest such neighbor** (nearest by centroid distance; ties → larger area, then id). Standalone cities and micro-districts collapse into the region around them: `US-DC` (191 km²) into Maryland, `FR-75` Paris (59 km²) into Hauts-de-Seine, the UK's 232 admin-1 districts down to **16** regions, and all of North Macedonia's 85 municipalities into one whole-country region.
- **Islands are exempt.** A region with no same-country neighbor within 0.2° keeps its shape whatever its size — **83** such small islands survive in the final set (Corsica, the Kerguelen/Crozet archipelagos, the Bahamas districts, Lord Howe, …) and the coarsening's fixed point leaves **0** non-island regions below the floor.
- **Real dissolve, not overlap.** Each merge is a real boolean union (`@turf/union`, polygon clipping) that removes the shared boundary between the merged region and its host, yielding one clean polygon — the final dataset has **0** fallback (non-dissolved) unions. Passes iterate smallest-area-first to a fixed point (2 passes on the v0.13 data, ~7 s); the output is sorted by (parent, id) at 4-dp precision and is byte-reproducible: re-running on the coarsened file reaches the fixed point immediately and rewrites an identical file (sha256-verified).
- **Numbers.** Per-country region count: min 1 / median 7 / max 80 (Russia; was 1 / 15 / 232 — the UK). Region areas: median 23,933 km²; smallest ≈0 (degenerate micro-dots like Ragged Island and the Paracel Islands, kept as islands); largest is Antarctica itself (≈21.8M km² in the planar model, exempt — it wraps a full 360°). 158 of the 177 countries had at least one merge. The file is 2,431 KB (2.37 MB) — under the 5 MB guard, so no Douglas–Peucker pass was needed.
- **Tooling + tests.** New `scripts/coarsen-regions.mjs` (`npm run coarsen:regions`) performs the merge in place; `scripts/regions.test.ts` gains a sixth test asserting that below-floor non-island regions stay under 1% of the dataset (currently 0) alongside the existing well-formed / unique-id / 177-country coverage / area / coverage-band checks (56 tests total). The map's data contract is unchanged (`properties.parent` → RegionMap), so `WorldMap.tsx`, `App.tsx`, `shared/protocol.ts`, and `server/map.ts` all keep working as-is.
- Part of #16.
## 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).
- **New: remove button for queued items (#11).** Each pending-queue row in "Your move" has a small trash-icon button that opens a shadcn `Dialog` confirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the new `POST /api/games/:id/queue/remove` (body `{ index }`) — same fast save → broadcast → respond order as the add routes, no LLM, no turn advance; invalid indices get a 400. The client `api.removeQueueItem(id, index)` is version-gated like every other state update, so the list can never regress after a removal.
- **Unit tests** — new `removeRoundItem` tests (valid removal + version bump, out-of-bounds/non-integer → `null`), version-bump assertions for `queueRoundItem`/`applyTurnResult`/stub turn, a legacy-no-version case, and version round-trip in `db.test.ts` (39 tests, up from 35).
- **E2E** — new "queue add is never clobbered by a stale SSE snapshot" test (one add appears immediately, still there after a 1s grace period for late SSE events, version strictly increases across adds, queue survives a full page reload) and a "remove with confirmation" test (cancel keeps the item, confirm removes it, no turn advance, version persisted) — all deterministic under `LLM_STUB=1`.
## v0.3.0 — 2026-10-11 15:29
- **Deep-linking** — the active game's id now lives in the URL (`#/game/<id>`), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.
## v0.2.0 — 2026-10-11 14:24
- **Batched round model** — replaced the single-action/turn with a queued-round model. Actions and diplomatic messages accumulate in `game.pending` (no LLM call, no turn advance); a single "Jump forward" button commits the whole batch in one LLM turn. This fixes the 502 reliability problem by making each LLM call do one coherent batch of work instead of one tiny fragment.
- **New routes** — `POST /api/games/:id/action` and `POST /api/games/:id/diplomacy` (fast, no LLM); `POST /api/games/:id/jump` (the single LLM call); `POST /api/games/:id/turn` removed.
- **New client API functions** — `api.addAction(id, text)`, `api.addDiplomacy(id, to, message)`, `api.jump(id)`; `api.turn(...)` removed.
- **ActionPanel rewrite** — Action tab: textarea + small "Add action" button. Diplomacy tab: target select + textarea + small "Add to round" button. Advisor tab: input + "Ask advisor" button. Below the tabs: a visible pending-queue list (⚡ for action, 💬 for diplomacy with target name, truncated text, count badge) and a large "Jump forward" primary button that shows the pending count and disables while a jump is in flight.
- **App.tsx** — `runAddAction` / `runAddDiplomacy` handlers (no busy state); `runJump` (with busy); the "Your move" window's title bar now shows a pending-count badge chip instead of the old jump button.
- **Deterministic stub updated** — `deterministicTurn` now emits one event per queued item (action → military/success, diplomacy → diplomatic/partial) instead of one event per turn; still sets player military to `clamp(cur+5)` and allies with the first other nation.
- **Unit tests** — rewrote `engine.test.ts` with batch-aware tests (7 new tests, 35 total up from 28); updated `db.test.ts` and `map.test.ts` fixtures to include `pending: []`.
- **E2E** — rewrote `e2e/sim.spec.ts` to the new flow: queue action → assert pending list → queue diplomacy → assert pending list → jump → assert turn+1, date+2, military 80→85, two events, pending cleared, ally relation.
- **AGENTS.md** — added Gitea-issue step to the change process; updated Layout and Conventions to reflect the batched-round model.
## v0.1.0 — 2026-10-11 08:02 ## v0.1.0 — 2026-10-11 08:02
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`: `typecheck → lint → unit → build → deterministic Playwright e2e`). E2e runs against the app's own dev webServer (`LLM_STUB=1`) — no dind/docker. - Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`: `typecheck → lint → unit → build → deterministic Playwright e2e`). E2e runs against the app's own dev webServer (`LLM_STUB=1`) — no dind/docker.
+8 -6
View File
@@ -1,11 +1,11 @@
# History # History
A self-contained, AI-driven **alternate-history simulator** in the spirit of _Pax Historia_ — the A self-contained, AI-driven **alternate-history simulator** in the spirit of _Pax Historia_ — the
LLM is the world engine. Pick one of five seed scenarios, choose a nation to lead, then take LLM is the world engine. Pick one of five seed scenarios, choose a nation to lead, then queue
actions, send diplomatic messages, or consult an advisor. Every move is sent to the model, which actions and diplomatic messages into a pending round; a single "Jump forward" sends the whole batch
returns a structured `TurnResult` (events + stat / relation / territory deltas) that the engine to the model, which returns a structured `TurnResult` (events + stat / relation / territory deltas)
validates, clamps, and applies to the world state. A MapLibre choropleth renders who owns what; that the engine validates, clamps, and applies to the world state. A MapLibre choropleth renders
everything persists to SQLite and streams over SSE on a single port. who owns what; everything persists to SQLite and streams over SSE on a single port.
``` ```
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐ ┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
@@ -91,7 +91,9 @@ before auto-merging. After merge, tag the version and deploy manually (`docker c
## Design notes ## Design notes
- **Server is authoritative.** All world state lives in SQLite; the client only renders state and - **Server is authoritative.** All world state lives in SQLite; the client only renders state and
sends intents (`turn` / `jump` / `advisor`). The engine never trusts raw model output — it strips sends intents (`action` / `diplomacy` / `jump` / `advisor`). Actions and diplomatic messages are
queued into `game.pending` (no LLM call, no turn advance); a single `jump` ("Jump forward")
commits the whole batch in one LLM turn. The engine never trusts raw model output — it strips
fences, extracts the first balanced `{…}`, `JSON.parse`s, ignores invalid ops, clamps stats to fences, extracts the first balanced `{…}`, `JSON.parse`s, ignores invalid ops, clamps stats to
0–100, retries once with a nudge, and if still unusable emits a generic "unclear" event and 0–100, retries once with a nudge, and if still unusable emits a generic "unclear" event and
advances. Bad model output never crashes; only a network failure (model down) surfaces as a advances. Bad model output never crashes; only a network failure (model down) surfaces as a
+533 -35
View File
@@ -2,19 +2,20 @@ import { test, expect, type Page } from '@playwright/test';
/** /**
* Fully deterministic end-to-end test. The Playwright webServer boots the real * Fully deterministic end-to-end test. The Playwright webServer boots the real
* stack (Vite :5173 → Express :4321) with LLM_STUB=1, so the backend uses the * stack (Vite → Express) with LLM_STUB=1, so the backend uses the deterministic
* deterministic turn simulator in server/stub.ts: the player nation's `military` * turn simulator in server/stub.ts.
* stat is set to (current + 5, clamped) each turn and exactly one event is
* emitted (action → military, chat → diplomatic, jump → political). There is no
* live LLM and no flakiness — every assertion below is an exact, predicted value.
* *
* Beyond the core simulation loop it also asserts the polished command-center * Batched-round model: actions and diplomatic messages are queued in
* UI: the dossier's animated stat bars, the map legend (generated from the * `game.pending` (no turn advance, no LLM call); a single "Jump forward"
* region map), and the new event cards (type icon + label + colored outcome * commits the whole batch in one LLM turn. The stub emits one event per
* badge). The map itself is a canvas, so we assert on its DOM legend, not pixels. * queued item (action → military/success, diplomacy → diplomatic/partial) or
* one political "The world moves on" event when the round is empty. It always
* sets the player's military to (current + 5, clamped) and allies the player
* with the first other nation, advancing the date by 2 days.
*/ */
test.describe('History — deterministic simulation (LLM_STUB=1)', () => { test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
test('scenario → nation → action → jump, with predicted stat changes', async ({ page }) => { test('scenario → nation → queue action + diplomacy → jump, with predicted stat changes', async ({ page }) => {
test.setTimeout(120000);
await page.goto('/'); await page.goto('/');
// Home: the scenario grid loads with all 5 presets. // Home: the scenario grid loads with all 5 presets.
@@ -44,9 +45,11 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
await expect(page.getByTestId('stat-stability')).toBeVisible(); await expect(page.getByTestId('stat-stability')).toBeVisible();
await expect(page.getByTestId('stat-population')).toBeVisible(); await expect(page.getByTestId('stat-population')).toBeVisible();
// The player (UK) starts at military 80, with no events yet. // The player (UK) starts at military 80, date 1939-09-01, no events yet.
const military = page.getByTestId('stat-military'); const military = page.getByTestId('stat-military');
const date = page.getByTestId('game-date');
await expect(military).toHaveText('80'); await expect(military).toHaveText('80');
await expect(date).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0); await expect(page.getByTestId('event-item')).toHaveCount(0);
// The map legend is auto-generated from the region map: all 7 nations of the // The map legend is auto-generated from the region map: all 7 nations of the
@@ -54,34 +57,63 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
await expect(page.getByTestId('map-legend-item')).toHaveCount(7, { timeout: 15000 }); await expect(page.getByTestId('map-legend-item')).toHaveCount(7, { timeout: 15000 });
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'United Kingdom' })).toBeVisible(); await expect(page.getByTestId('map-legend-item').filter({ hasText: 'United Kingdom' })).toBeVisible();
// Take an action: the stub bumps UK military to 85 and emits a military event. // --- Queue an action (no turn advance, no LLM call) ---
await page.getByTestId('action-textarea').fill('Mobilize the home guard and fortify Dover'); await page.getByTestId('action-textarea').fill('Mobilize the home guard and fortify Dover');
await page.getByTestId('action-submit').click(); await page.getByTestId('add-action').click();
// The pending list shows one queued action; turn and date are unchanged.
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').first()).toContainText('Mobilize the home guard and fortify Dover');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(date).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The "Your move" window's title badge shows the pending count.
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
// --- Queue a diplomatic message (no turn advance, no LLM call) ---
await page.getByTestId('tab-diplo').click();
await page.getByTestId('chat-target').click();
await page.getByRole('option', { name: 'United States' }).click();
await page.getByTestId('chat-message').fill('We propose a mutual defense pact');
await page.getByTestId('add-diplomacy').click();
// The pending list now shows two items (action + diplomacy); turn still 1.
await expect(page.getByTestId('pending-item')).toHaveCount(2);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').nth(1)).toHaveAttribute('data-kind', 'diplomacy');
await expect(page.getByTestId('pending-item').nth(1)).toContainText('→ United States');
await expect(page.getByTestId('pending-item').nth(1)).toContainText('We propose a mutual defense pact');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(date).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
await expect(page.getByTestId('title-queued')).toHaveText('2 queued');
// --- Jump forward: commit the whole pending round in a single LLM turn ---
// The button sits at the bottom of the scrollable "Your move" window body
// (max-h-[72vh] overflow-y-auto) and may be outside the page viewport; a
// native DOM click bypasses Playwright's viewport actionability check.
await page
.getByTestId('jump-forward')
.evaluate((el) => (el as HTMLElement).click());
// Turn advances to 2, date to 1939-09-03 (+2 days), military 80 → 85.
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 }); await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(military).toHaveText('85', { timeout: 20000 }); await expect(military).toHaveText('85', { timeout: 20000 });
await expect(page.getByTestId('event-item')).toHaveCount(1); await expect(date).toHaveText('1939-09-03', { timeout: 20000 });
await expect(
page.getByTestId('event-feed').getByText('Mobilize the home guard and fortify Dover').first(),
).toBeVisible();
// The new event card carries its type (icon + label) and a colored outcome badge. // Two events were emitted (one per queued item): one military, one diplomatic.
await expect(page.getByTestId('event-item').first().getByTestId('event-type-icon')).toBeVisible();
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('military');
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('success');
// Jump forward (no action): a "world moves on" political event, military → 90.
await page.getByTestId('jump-forward').click();
await expect(page.getByTestId('turn-count')).toHaveText('3', { timeout: 20000 });
await expect(military).toHaveText('90', { timeout: 20000 });
await expect(page.getByTestId('event-item')).toHaveCount(2); await expect(page.getByTestId('event-item')).toHaveCount(2);
await expect(page.getByTestId('event-feed').getByText('The world moves on').first()).toBeVisible();
// Newest first: the jump event (political / neutral) sits on top, and the // The pending queue is now empty — the round was committed and cleared.
// action event (military / success) is second — both with their outcome badges. await expect(page.getByTestId('pending-empty')).toBeVisible();
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political'); await expect(page.getByTestId('title-queued')).not.toBeVisible();
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
// Newest first: the diplomatic event (2nd queued item) sits on top, the
// military event (1st queued item) is second.
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('diplomatic');
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('partial');
await expect(page.getByTestId('event-item').nth(1).getByTestId('event-type')).toHaveText('military');
await expect(page.getByTestId('event-item').nth(1).getByTestId('event-outcome')).toHaveText('success'); await expect(page.getByTestId('event-item').nth(1).getByTestId('event-outcome')).toHaveText('success');
}); });
}); });
@@ -91,7 +123,7 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
* localStorage, so a reload (or the home screen's "Resume last game" button) * localStorage, so a reload (or the home screen's "Resume last game" button)
* must drop the user straight back into the same game — same turn, same stats, * must drop the user straight back into the same game — same turn, same stats,
* same map — rather than resetting to the home screen. Deterministic under the * same map — rather than resetting to the home screen. Deterministic under the
* stub (UK military starts at 80 and +5 per turn). * stub (UK military starts at 80 and +5 per committed turn).
*/ */
test.describe('History — save / resume across a page refresh', () => { test.describe('History — save / resume across a page refresh', () => {
async function createUkGameAtTurn2(page: Page): Promise<void> { async function createUkGameAtTurn2(page: Page): Promise<void> {
@@ -108,9 +140,12 @@ test.describe('History — save / resume across a page refresh', () => {
await expect(page.getByTestId('turn-count')).toHaveText('1'); await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('stat-military')).toHaveText('80'); await expect(page.getByTestId('stat-military')).toHaveText('80');
// One action: the stub bumps UK military to 85 and advances to turn 2. // Queue one action onto the pending round, then jump forward: the stub
// bumps UK military to 85 and advances to turn 2.
await page.getByTestId('action-textarea').fill('Reinforce the Atlantic convoys'); await page.getByTestId('action-textarea').fill('Reinforce the Atlantic convoys');
await page.getByTestId('action-submit').click(); await page.getByTestId('add-action').click();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await page.getByTestId('jump-forward').click();
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 }); await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 }); await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
} }
@@ -145,3 +180,466 @@ test.describe('History — save / resume across a page refresh', () => {
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom'); await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
}); });
}); });
/**
* Queue reliability — issue #10. A queued item must appear in the pending list
* right after its REST response AND stay there: before the fix, a stale SSE
* `state` snapshot (the on-connect resync taken just before the add was saved,
* carrying no freshness marker) could arrive after the REST response and
* clobber it — "add one: nothing happens, add another: both appear". The fix
* gives every mutation a monotonic `GameState.version` and the client applies
* an incoming state only when it is not older than the current one.
*/
test.describe('History — queue add is never clobbered by a stale SSE snapshot (#10)', () => {
test('one added action appears immediately and survives late SSE snapshots', async ({
page,
}) => {
test.setTimeout(90000);
const id = await createUkGame(page);
const origin = await page.evaluate(() => location.origin);
// Game screen at turn 1, nothing queued yet.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('pending-empty')).toBeVisible();
// Queue exactly ONE action and assert it appears immediately (turn/date
// unchanged, no events yet).
await page.getByTestId('action-textarea').fill('Hold the line at the channel');
const [addResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const addBody = (await addResp.json()) as { game: { version: number; pending: unknown[] } };
expect(addBody.game.pending).toHaveLength(1);
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
// Let any in-flight / late SSE snapshot (on-connect resync, auto-reconnect)
// have its chance to arrive. Under the stub these settle in milliseconds;
// a second is generous. Before the version fix, a stale one could clobber
// the just-added item here.
await page.waitForTimeout(1000);
// The item is still there — the stale snapshot was dropped, not applied.
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Protocol-level check: a later mutation (a second queued item) must carry
// a strictly higher version than the one we just saw.
await page.getByTestId('action-textarea').fill('Scramble the night fighters');
const [add2Resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const add2Body = (await add2Resp.json()) as { game: { version: number } };
expect(add2Body.game.version).toBeGreaterThan(addBody.game.version);
await expect(page.getByTestId('pending-item')).toHaveCount(2);
// The game persisted with a version survives a full page reload.
await page.goto(`${origin}/#/game/${id}`);
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('pending-item')).toHaveCount(2, { timeout: 15000 });
});
});
/**
* Per-item removal — issue #11. Each queued row has a small trash button that
* opens a confirmation dialog; confirming removes the item server-side
* (POST /api/games/:id/queue/remove, no turn advance) and cancelling keeps it.
*/
test.describe('History — remove a queued item with confirmation (#11)', () => {
test('cancel keeps the item; confirm removes it', async ({ page }) => {
test.setTimeout(90000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Queue one action.
await page.getByTestId('action-textarea').fill('Open the back channel with Vichy');
await page.getByTestId('add-action').click();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
// Trash icon → confirmation dialog.
await page.getByTestId('remove-queue-item').click();
const dialog = page.getByTestId('remove-confirm-dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('Open the back channel with Vichy');
// Cancel: the item stays queued.
await page.getByTestId('remove-cancel').click();
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Open again and confirm: the item is removed server-side.
await page.getByTestId('remove-queue-item').click();
await expect(dialog).toBeVisible();
const [removeResp] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname === `/api/games/${id}/queue/remove`,
),
page.getByTestId('remove-confirm').click(),
]);
const removeBody = (await removeResp.json()) as { game: { version: number; pending: unknown[] } };
expect(removeBody.game.pending).toHaveLength(0);
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-empty')).toBeVisible();
await expect(page.getByTestId('title-queued')).not.toBeVisible();
// Still turn 1, no events — removing never advances the round.
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The bumped version is persisted with the game (it survives a reload).
const after = await page.evaluate(
async (gameId) =>
((await fetch(`/api/games/${gameId}`)).json()) as Promise<{ game: { version: number } }>,
id,
);
expect(after.game.version).toBe(removeBody.game.version);
});
});
/**
* URL deep-linking (issue #3). The active game's id lives in the URL fragment:
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
* that hash loads that exact game; a bad/missing id falls back to home and
* clears the hash; returning to home clears it. A browser context is fresh per
* test, so localStorage is isolated, but the e2e webServer shares one SQLite
* (`data/e2e`) across tests — so each test captures the *specific* id of the
* game it creates (via the POST /api/games response) rather than assuming "the
* only game".
*/
/**
* Start the first scenario (World War II) and pick the United Kingdom, returning
* the freshly created game's id (read from the POST /api/games response). Leaves
* the caller on the game screen at turn 1.
*/
async function createUkGame(page: Page): Promise<string> {
await page.goto('/');
await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 });
await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click();
await expect(page.getByTestId('world-background')).toBeVisible();
const [resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/games',
),
page
.getByTestId('nation-card-uk')
.getByRole('button', { name: 'Play as United Kingdom' })
.click(),
]);
const body = (await resp.json()) as { game: { id: string } };
return body.game.id;
}
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
async function waitHash(page: Page, expected: string): Promise<void> {
await page.waitForFunction(
(exp) => window.location.hash === exp,
expected,
{ timeout: 15000 },
);
}
test.describe('History — URL deep-linking (#/game/<id>)', () => {
test('creating a game puts its id in the URL', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
// The game screen mounts at turn 1; the URL-sync effect writes the deep link.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page, `#/game/${id}`);
});
test('a fresh page deep-links to the exact saved game', async ({ page, context }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await waitHash(page, `#/game/${id}`);
const origin = await page.evaluate(() => location.origin);
// A brand-new page navigated straight to the deep link must load that exact
// game (matching id, preset, and player) — the URL hash takes precedence over
// the (shared) localStorage, so this is deterministic.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/${id}`);
await expect(page2.getByTestId('world-map')).toBeVisible();
await expect(page2.getByTestId('turn-count')).toHaveText('1');
await expect(page2.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page2, `#/game/${id}`);
await page2.close();
});
test('a bad game id falls back to the home screen and clears the hash', async ({
page,
context,
}) => {
test.setTimeout(60000);
await page.goto('/');
const origin = await page.evaluate(() => location.origin);
// A fresh page navigated to a non-existent id must not crash: it lands on the
// home screen and the bad hash is cleared to `#/`.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/does-not-exist-xyz`);
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
await waitHash(page2, '#/');
await page2.close();
});
test('returning to home from a game clears the hash to #/', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await waitHash(page, `#/game/${id}`);
// "← Scenarios" returns to the home screen; the URL-sync effect clears the hash.
await page.getByTestId('back-to-scenarios').click();
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 15000 });
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', 'vitest.config.ts',
'playwright.config.ts', 'playwright.config.ts',
'scripts/**/*.mjs', 'scripts/**/*.mjs',
'scripts/**/*.ts',
], ],
languageOptions: { globals: { ...globals.node, ...globals.es2022 } }, languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
rules: { rules: {
+2462 -2
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.1.0", "version": "0.19.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -9,6 +9,11 @@
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3", "build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
"start": "node server/dist/index.js", "start": "node server/dist/index.js",
"test": "vitest run", "test": "vitest run",
"generate:regions": "node scripts/generate-regions.mjs",
"coarsen:regions": "node scripts/coarsen-regions.mjs",
"cleanup:regions": "node scripts/cleanup-regions.mjs",
"smooth:regions": "node scripts/smooth-regions.mjs",
"fill:regions": "node scripts/fill-gaps.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"e2e": "playwright test" "e2e": "playwright test"
@@ -38,6 +43,15 @@
"@eslint/js": "^9.20.0", "@eslint/js": "^9.20.0",
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@turf/area": "^7.4.0",
"@turf/bbox": "^7.4.0",
"@turf/boolean-point-in-polygon": "^7.4.0",
"@turf/centroid": "^7.4.0",
"@turf/difference": "^7.4.0",
"@turf/distance": "^7.4.0",
"@turf/simplify": "^7.4.0",
"@turf/turf": "^7.4.0",
"@turf/union": "^7.4.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
File diff suppressed because one or more lines are too long
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env node
/**
* scripts/cleanup-regions.mjs — removes the small holes left by coarsening.
*
* Run: npm run cleanup:regions (in place on public/regions.geo.json)
* node scripts/cleanup-regions.mjs [out] [src] [--bridge]
*
* The v0.14 coarsening produced a clean 1,796-region dataset, but small
* unfilled patches ("holes") remain inside regions — artifacts of the boolean
* dissolves and Natural Earth's own digitization. This fixes that in place,
* WITHOUT touching the dataset's contract (the region count, the `id`s, the
* `properties = { name, parent }`, the ~100 km floor and the island exemption
* are all preserved — only inner rings are dropped).
*
* ENCLOSED HOLES — every hole (inner ring) whose area is < HOLE_AREA_MAX_KM2
* is dropped, so the region fills in to be solid. Any larger hole (a genuine
* water body) is kept. NOTE: the big real lakes (Great Lakes, Caspian, Aral,
* Lake Superior, …) were already dissolved by the v0.14 coarsening, so they
* are not present as holes here; the only hole above the threshold in this
* dataset is a single medium one (~5,858 km2, kept). A 1,000 km2 threshold
* is comfortably above the 2,252 tiny artifact holes (all < 500 km2) and
* comfortably below any large real lake, so it fills exactly the artifacts.
*
* BRIDGING (opt-in, `--bridge`) — a MultiPolygon whose separate parts sit
* only a short distance apart (a thin gap across a strait, a nearby island)
* leaves a visible crack of background. This joins each such close pair with
* a small connector polygon and a boolean union, closing the crack while
* genuinely far parts (islands, separated territories) stay separate. It is
* OFF by default: the union itself can mint new (medium) holes, which is
* counter to the primary "fill holes" goal, so the default run is the
* clean, deterministic hole-fill only.
*
* Deterministic + idempotent: fixed threshold, deterministic planar geometry,
* 4-dp rounding. Re-running on an already-clean file rewrites the same file
* (no small holes remain to fill; the kept holes are above the threshold).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { union } from '@turf/union';
import { featureCollection } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const DEFAULT_FILE = path.join(root, 'public', 'regions.geo.json');
// positional args (out, src) plus named flags; keep the two orthogonal
const args = process.argv.slice(2).filter((a) => !a.startsWith('-'));
const flags = process.argv.slice(2).filter((a) => a.startsWith('-'));
const BRIDGE = flags.includes('--bridge');
const OUT = args[0] ? path.resolve(args[0]) : DEFAULT_FILE;
const SRC = args[1] ? path.resolve(args[1]) : DEFAULT_FILE;
// --- parameters ---------------------------------------------------------------
const HOLE_AREA_MAX_KM2 = 1000; // fill enclosed holes smaller than this (keep real lakes)
const BRIDGE_MAX_KM = 30; // bridge part-gaps smaller than this …
const BRIDGE_MIN_KM = 0.3; // … but only real gaps (a pair in (BRIDGE_MIN, BRIDGE_MAX))
const BRIDGE_EXT_KM = 2; // extend each bridge end into its part (ensures union overlap)
const BRIDGE_HW_MIN_KM = 2; // minimum half-width of a connector
const BRIDGE_HW_MAX_KM = 20; // cap so a connector never becomes a big blob
const R4 = 4; // coordinate precision (decimal places)
const SLIVER_EPS_KM2 = 1e-6; // drop only truly-degenerate (≈zero-area) parts
// --- area model (identical to coarsen-regions.mjs / regions.test.ts) -----------
const KM_LON = 111.32;
const KM_LAT = 110.574;
const rad = (deg) => (deg * Math.PI) / 180;
function ringAreaKm2(ring) {
let lat = 0;
for (const [, la] of ring) lat += la;
const kx = KM_LON * Math.cos(rad(ring.length ? lat / ring.length : 0));
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 geomAreaKm2(geom) {
if (!geom) return 0;
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
let total = 0;
for (const poly of polys) total += ringAreaKm2(poly[0]);
return total;
}
// --- rounding / normalization (same conventions as coarsen-regions.mjs) --------
const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N;
function roundRing(ring, N = R4) {
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);
}
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;
return [...out, [...out[0]]];
}
function roundAll(geom) {
if (!geom || (geom.type !== 'Polygon' && geom.type !== 'MultiPolygon')) return null;
const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates)
.map((poly) => poly.map((ring) => roundRing(ring)))
.filter((poly) => poly.length >= 1 && poly.every((r) => r.length >= 3));
if (!polys.length) return null;
if (polys.length === 1 && geom.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
return { type: 'MultiPolygon', coordinates: polys };
}
function normalizeGeom(geom) {
const rounded = roundAll(geom);
if (!rounded) return null;
const all = rounded.type === 'Polygon' ? [rounded.coordinates] : rounded.coordinates;
const kept = all.filter((poly) => ringAreaKm2(poly[0]) >= SLIVER_EPS_KM2);
const polys = kept.length ? kept : all;
if (polys.length === 1 && rounded.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
return { type: 'MultiPolygon', coordinates: polys };
}
// --- closest-point measurement (decimated planar point↔segment, ~km) ----------
function toXY(ring) {
let lat = 0;
for (const [, la] of ring) lat += la;
const kx = KM_LON * Math.cos(rad(ring.length ? lat / ring.length : 0));
return ring.map(([lon, lat]) => [lon * kx, lat * KM_LAT]);
}
function dec(pts, maxN = 200) {
if (pts.length <= maxN) return pts;
const step = Math.ceil(pts.length / maxN);
const out = [];
for (let i = 0; i < pts.length; i += step) out.push(pts[i]);
return out;
}
/**
* Closest point-pair between two planar rings: returns { d, p, q } with p on A,
* q on B and d = |p-q| in km (both on the decimated rings — a good-enough
* approximation for bridging; a tiny offset is corrected by the extension).
*/
function ringClosest(AX, BX) {
const A = dec(AX), B = dec(BX);
let best = Infinity, bestP = null, bestQ = null;
for (const p of A) {
for (let i = 0; i < B.length - 1; i++) {
const a = B[i], b = B[i + 1];
const dx = b[0] - a[0], dy = b[1] - a[1];
const l2 = dx * dx + dy * dy;
let t = l2 === 0 ? 0 : ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
t = Math.max(0, Math.min(1, t));
const c = [a[0] + t * dx, a[1] + t * dy];
const dd = Math.hypot(p[0] - c[0], p[1] - c[1]);
if (dd < best) { best = dd; bestP = p; bestQ = c; }
}
}
for (const p of B) {
for (let i = 0; i < A.length - 1; i++) {
const a = A[i], b = A[i + 1];
const dx = b[0] - a[0], dy = b[1] - a[1];
const l2 = dx * dx + dy * dy;
let t = l2 === 0 ? 0 : ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
t = Math.max(0, Math.min(1, t));
const c = [a[0] + t * dx, a[1] + t * dy];
const dd = Math.hypot(p[0] - c[0], p[1] - c[1]);
if (dd < best) { best = dd; bestP = c; bestQ = p; }
}
}
return { d: best, p: bestP, q: bestQ };
}
/**
* Build a thin connector (a quad in lon/lat) between two closest points p, q
* (both in planar km). The connector is extended a little into each part so the
* boolean union joins them, and its half-width is wide enough to span the local
* gap (capped so it never becomes a big blob).
*/
function makeBridgeRing(p, q) {
const gap = Math.hypot(q[0] - p[0], q[1] - p[1]);
if (gap < 1e-3) return null; // coincident → nothing to bridge
const midLat = (p[1] / KM_LAT + q[1] / KM_LAT) / 2;
const kx = KM_LON * Math.cos(rad(midLat));
const ux = (q[0] - p[0]) / gap, uy = (q[1] - p[1]) / gap; // unit p→q
const nx = -uy, ny = ux; // perpendicular
const hw = Math.min(BRIDGE_HW_MAX_KM, Math.max(BRIDGE_HW_MIN_KM, gap / 2));
const ext = BRIDGE_EXT_KM;
const pExt = [p[0] - ux * ext, p[1] - uy * ext];
const qExt = [q[0] + ux * ext, q[1] + uy * ext];
const toLL = ([x, y]) => [rN(x / kx, R4), rN(y / KM_LAT, R4)];
const c1 = toLL([pExt[0] + nx * hw, pExt[1] + ny * hw]);
const c2 = toLL([qExt[0] + nx * hw, qExt[1] + ny * hw]);
const c3 = toLL([qExt[0] - nx * hw, qExt[1] - ny * hw]);
const c4 = toLL([pExt[0] - nx * hw, pExt[1] - ny * hw]);
return [c1, c2, c3, c4, c1];
}
/** Bridge all close pairs of a MultiPolygon and union everything into one shape. */
function bridgeParts(geom) {
const parts = geom.coordinates;
const partsXY = parts.map((p) => toXY(p[0]));
const bridges = [];
let bridgedPairs = 0;
for (let i = 0; i < partsXY.length; i++) {
for (let j = i + 1; j < partsXY.length; j++) {
const { d, p, q } = ringClosest(partsXY[i], partsXY[j]);
if (d > BRIDGE_MIN_KM && d < BRIDGE_MAX_KM) {
const ring = makeBridgeRing(p, q);
if (ring) { bridges.push(ring); bridgedPairs++; }
}
}
}
if (!bridges.length) return { geom, bridgedPairs: 0, changed: false };
// union: original parts (as Polygons) + connector rings (as Polygons)
const feats = parts.map((poly) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: poly } }));
for (const ring of bridges) feats.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } });
let u;
try {
u = union(featureCollection(feats));
} catch (e) {
console.error(` [warn] bridge union failed: ${e.message}`);
return { geom, bridgedPairs: 0, changed: false };
}
const norm = u ? normalizeGeom(u.geometry) : null;
// Guard: a good bridge only adds a little area. Reject the union if it lost
// area (< 0.8×, a bad union that ate parts) or over-filled (> 1.3×, e.g. many
// overlapping bridges in a dense archipelago) — keep the original instead.
if (norm) {
const na = geomAreaKm2(norm);
const oa = geomAreaKm2(geom);
if (oa > 0 && (na < 0.8 * oa || na > 1.3 * oa)) {
return { geom, bridgedPairs: 0, changed: false };
}
}
return { geom: norm ?? geom, bridgedPairs, changed: true, bridgedParts: u ? (u.geometry.type === 'Polygon' ? 1 : u.geometry.coordinates.length) : parts.length };
}
// --- main ----------------------------------------------------------------------
function main() {
const t0 = Date.now();
const fc = JSON.parse(readFileSync(SRC, 'utf8'));
const inKB = (Buffer.byteLength(readFileSync(SRC)) / 1024).toFixed(0);
console.log(`input: ${fc.features.length} regions (${inKB} KB) from ${SRC}`);
console.log(`mode: holes <${HOLE_AREA_MAX_KM2} km2 filled, larger kept${BRIDGE ? `; + bridging part-gaps in (${BRIDGE_MIN_KM}, ${BRIDGE_MAX_KM}) km` : ''}`);
// diagnostics
let holesFilled = 0, holesFilledArea = 0, holesKept = 0, holesKeptArea = 0, featuresTouchedHole = 0;
let bridgedFeatures = 0, bridgedPairs = 0, partsBefore = 0, partsAfter = 0, areaBefore = 0, areaAfter = 0;
const bridgeSamples = [];
const keptHoleSamples = [];
const outFeatures = [];
for (const f of fc.features) {
let geom = f.geometry;
// ---- Opt-in: bridge close parts (additive connector + union) ----
if (BRIDGE && geom.type === 'MultiPolygon' && geom.coordinates.length >= 2) {
const before = { parts: geom.coordinates.length, area: geomAreaKm2(geom) };
const res = bridgeParts(geom);
if (res.changed) {
geom = res.geom;
bridgedFeatures++;
bridgedPairs += res.bridgedPairs;
partsBefore += before.parts;
partsAfter += geom.type === 'Polygon' ? 1 : geom.coordinates.length;
areaBefore += before.area;
areaAfter += geomAreaKm2(geom);
if (bridgeSamples.length < 20)
bridgeSamples.push(`${f.id} ${f.properties.name} (${f.properties.parent}): ${before.parts}p→${geom.type === 'Polygon' ? 1 : geom.coordinates.length}p, ${res.bridgedPairs} bridge(s), ${Math.round(before.area)}→${Math.round(geomAreaKm2(geom))} km2`);
}
}
// ---- Fill small enclosed holes: drop inner rings < threshold, keep larger ----
const isMulti = geom.type === 'MultiPolygon';
const polys = isMulti ? geom.coordinates : [geom.coordinates];
let touchedHole = false;
const newPolys = polys.map((poly) => {
const kept = [poly[0]]; // outer ring always kept
for (let i = 1; i < poly.length; i++) {
const area = ringAreaKm2(poly[i]);
if (area < 1e-6) continue; // degenerate — drop
if (area < HOLE_AREA_MAX_KM2) {
holesFilled++; holesFilledArea += area; touchedHole = true; // fill (drop)
} else {
holesKept++; holesKeptArea += area;
kept.push(poly[i]); // a genuine water body — keep the hole
if (keptHoleSamples.length < 20) keptHoleSamples.push(`${f.id}(${f.properties.parent}) ${Math.round(area)} km2`);
}
}
return kept;
});
if (touchedHole) featuresTouchedHole++;
const rebuilt = isMulti
? { type: 'MultiPolygon', coordinates: newPolys }
: { type: 'Polygon', coordinates: newPolys[0] };
// normalize (round 4dp, drop degenerate). NEVER drop a feature: if nothing
// usable survives (a degenerate 3-pt 0-area micro-dot), keep the
// hole-filled (rebuilt) geometry, else the untouched original.
const norm = normalizeGeom(rebuilt);
outFeatures.push({ type: 'Feature', id: f.id, properties: { name: f.properties.name, parent: f.properties.parent }, geometry: norm ?? rebuilt ?? geom });
}
const out = { type: 'FeatureCollection', features: outFeatures };
const json = JSON.stringify(out);
const outKB = (Buffer.byteLength(json) / 1024).toFixed(0);
writeFileSync(OUT, json + '\n');
console.log(`\n--- RESULTS ---`);
console.log(`regions: ${fc.features.length} → ${out.features.length} (should be unchanged)`);
console.log(`file: ${inKB} KB → ${outKB} KB (${(outKB / 1024).toFixed(2)} MB)`);
console.log(`\nENCLOSED HOLES:`);
console.log(` filled ${holesFilled} holes (${Math.round(holesFilledArea).toLocaleString('en-US')} km2) in ${featuresTouchedHole} features`);
console.log(` kept ${holesKept} hole(s) (${Math.round(holesKeptArea).toLocaleString('en-US')} km2) as real water`);
if (keptHoleSamples.length) console.log(` kept-hole list: ${keptHoleSamples.join(', ')}`);
if (BRIDGE) {
console.log(`\nBRIDGING (part-gaps):`);
console.log(` bridged ${bridgedFeatures} features (${bridgedPairs} close pairs): parts ${partsBefore} → ${partsAfter}, area ${Math.round(areaBefore).toLocaleString('en-US')} → ${Math.round(areaAfter).toLocaleString('en-US')} km2`);
for (const s of bridgeSamples) console.log(` ${s}`);
} else {
console.log(`\nbridging: skipped (pass --bridge to enable)`);
}
console.log(`\nwrote ${OUT}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
main();
+612
View File
@@ -0,0 +1,612 @@
#!/usr/bin/env node
/**
* scripts/coarsen-regions.mjs — coarsens the committed `public/regions.geo.json`
* in place, so the map is genuinely coarser, not just finer-grained.
*
* Rule (per user request: "focus on joining cities to the region around it.
* Regions shouldn't be smaller than around 100x100km, excluding islands"):
*
* FLOOR — no region should be smaller than ~100×100 km = 10,000 km²,
* EXCEPT islands.
* ISLAND — a region is an island if it has NO same-country neighbor: no
* other region with the same `properties.parent` whose bounding box
* is within CLOSE_DEG (≈≤22 km) of its own. Small islands stay
* as-is regardless of size (Corsica, the Hawaiian counties,
* Okinawa, the Faroes, island micro-states, …).
* MERGE — a region that is small (area < FLOOR) AND not an island is
* merged into its NEAREST same-country neighbor (nearest by
* centroid distance; tie-break → larger area, then lexicographic
* id). Standalone cities (FR-75 Paris, the Latvian/Slovenian
* municipalities, the UK's 231 micro-districts) collapse into the
* region around them.
* DISSOLVE— the merge is a real boolean union (`@turf/union`, polygon-
* clipping): the shared boundary between the merged region and its
* host is removed, producing a single clean polygon — not two
* overlapping shapes. (Fallback if the union fails on a pair:
* combine the rings into a MultiPolygon without dissolving; the
* count of such fallbacks is reported.)
* ITERATE — passes run smallest-area-first (small regions merge into their
* neighbors, which grow and then absorb more). After each pass the
* area/neighbor/centroid metrics are recomputed; it stops at the
* fixed point (no small non-island region left) or at MAX_ITER.
* IDENTITY— the host (the region merged INTO) keeps its own `id` and
* `name`; the absorbed region's id is dropped. `properties.parent`
* is unchanged, so the map code (WorldMap/App/protocol/map.ts)
* keeps working untouched.
*
* Deterministic: fixed processing order (area, then id), deterministic
* neighbor choice (distance, then -area, then id), deterministic boolean
* union. Re-running on the coarsened file reaches the fixed point
* immediately and rewrites a byte-identical file.
*
* Area model: the SAME planar approximation the validation test uses
* (`scripts/regions.test.ts`) — `x = lon·111.32·cos(midLat)`,
* `y = lat·110.574` km, shoelace per OUTER ring (holes ignored), each ring at
* its own mean latitude — so the script's merge decisions and the test's
* assertions measure the same quantity.
*
* Longitude handling: every ring is unwrapped to a continuous [0,360)-derived
* space before its bbox/centroid is computed (the shortest-difference
* "np.unwrap" scheme), which makes the one full-360° antimeridian-wrapping
* feature (Antarctica's mainland ring) and any seam-straddling ring behave
* correctly; cross-feature longitude gaps use the 360°-cyclic minimum.
*
* Output: features sorted by (parent, id) (the same comparator as
* `scripts/generate-regions.mjs`), coordinates rounded to 4 decimal places,
* single line of JSON + trailing newline. If the file would exceed the
* ~5 MB guard, a light Douglas–Peucker pass (tolerances escalated through a
* small table) brings it under ~4 MB before writing.
*
* Run: `npm run coarsen:regions`
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { union } from '@turf/union';
import { feature, featureCollection } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const FILE = path.join(root, 'public', 'regions.geo.json');
// --- parameters ---------------------------------------------------------------
const AREA_FLOOR_KM2 = 10_000; // ~100×100 km floor for non-island regions
const CLOSE_DEG = 0.2; // "spatially close" bbox gap (both lat and lon ≤ this; ≈≤22 km)
const MAX_ITER = 20; // safety cap on fixed-point passes (spec: ~15)
const SIZE_GUARD_KB = 5_120; // keep the committed file under ~5 MB
const SIZE_TARGET_KB = 4_096; // …and land under ~4 MB once simplified
const DP_STEPS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]; // Douglas–Peucker tolerances (degrees)
const R4 = 4; // coordinate precision (decimal places, ≈11 m)
const SLIVER_EPS_KM2 = 1e-6; // drop only truly-degenerate (≈zero-area) clipper slivers
// --- area model (identical to scripts/regions.test.ts) -------------------------
const KM_LON = 111.32;
const KM_LAT = 110.574;
const rad = (deg) => (deg * Math.PI) / 180;
/** Shoelace area (km²) of one ring, measured at the ring's own mean latitude. */
function ringAreaKm2(ring) {
let lat = 0;
for (const [, la] of ring) lat += la;
const kx = KM_LON * Math.cos(rad(ring.length ? lat / ring.length : 0));
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);
}
/** Sum of a geometry's outer-ring areas (holes are lakes — ignored by design). */
function geomAreaKm2(geom) {
if (!geom) return 0;
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
let total = 0;
for (const poly of polys) total += ringAreaKm2(poly[0]);
return total;
}
// --- longitude unwrapping (antimeridian-safe) ----------------------------------
/**
* Unwrap a ring's longitudes to a continuous real line (the "np.unwrap"
* shortest-difference scheme, anchored at the first vertex's [0,360) value).
* Returns { lons, full } — `full` when the unwrapped span is ≥ 300°, i.e. the
* ring is a full-360° antimeridian band (Antarctica's mainland ring).
*/
function unwrapRing(ring) {
const n = ring.length;
const norm = (x) => ((x % 360) + 360) % 360; // → [0,360)
const u = new Array(n);
u[0] = norm(ring[0][0]);
for (let i = 1; i < n; i++) {
// The PREVIOUS unwrapped value may lie outside [0,360) (it accumulates),
// so normalize it back before measuring the difference; otherwise a bogus
// ~180° jump is inserted and the ring's span balloons to ~180°.
const a = norm(u[i - 1]);
const b = norm(ring[i][0]);
const d = (((b - a) % 360) + 540) % 360 - 180; // shortest signed diff in (-180, 180]
u[i] = u[i - 1] + d;
}
let min = Infinity;
let max = -Infinity;
for (const v of u) {
if (v < min) min = v;
if (v > max) max = v;
}
return { lons: u, full: max - min >= 300 };
}
/**
* Cyclic 1-D gap (degrees) between two tight longitude intervals in their own
* unwrapped [0,360)-derived spaces. Tries the three 360° shifts of `b` so
* different unwrap anchors reconcile; a `full` interval is close to everything.
*/
function lonGap(intA, intB) {
if (intA.full || intB.full) return 0;
let best = Infinity;
for (const k of [-1, 0, 1]) {
const a2 = intB.a + 360 * k;
const b2 = intB.b + 360 * k;
const gap = intA.b < a2 ? a2 - intA.b : b2 < intA.a ? intA.a - b2 : 0;
if (gap < best) best = gap;
}
return best;
}
// --- geometry helpers ----------------------------------------------------------
const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N;
/** Round a ring to 4 dp, drop consecutive duplicates, and (re-)close it. */
function roundRing(ring, N = R4) {
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);
}
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]]];
}
/**
* Round every ring of a geometry to 4 dp, drop degenerate rings (<3 pts) and
* empty polygons, keep the Polygon/MultiPolygon type (a single-part
* MultiPolygon collapses to Polygon). Returns null if nothing usable survived.
*/
function roundAll(geom) {
if (!geom || (geom.type !== 'Polygon' && geom.type !== 'MultiPolygon')) return null;
const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates)
.map((poly) => poly.map((ring) => roundRing(ring)))
.filter((poly) => poly.length >= 1 && poly.every((r) => r.length >= 3));
if (!polys.length) return null;
if (polys.length === 1 && geom.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
return { type: 'MultiPolygon', coordinates: polys };
}
/**
* Normalize a union output (or fallback MultiPolygon): roundAll, then drop
* truly-degenerate (≈zero-area, < SLIVER_EPS_KM2) polygons the clipper can
* leave behind at shared boundaries. It never drops *every* part — a merge of
* two sub-resolution (zero-area) micro-regions must still collapse into one
* (degenerate) region rather than vanish. Returns null only when the input
* itself had no usable ring (e.g. `null`).
*/
function normalizeGeom(geom) {
const rounded = roundAll(geom);
if (!rounded) return null;
const all = rounded.type === 'Polygon' ? [rounded.coordinates] : rounded.coordinates;
const kept = all.filter((poly) => ringAreaKm2(poly[0]) >= SLIVER_EPS_KM2);
const polys = kept.length ? kept : all;
if (polys.length === 1 && rounded.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
return { type: 'MultiPolygon', coordinates: polys };
}
/** Douglas–Peucker 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 dx = b[0] - a[0];
const dy = b[1] - a[1];
const l2 = dx * dx + dy * dy;
let t = l2 === 0 ? 0 : ((pts[i][0] - a[0]) * dx + (pts[i][1] - a[1]) * dy) / l2;
t = Math.max(0, Math.min(1, t));
const d = Math.hypot(pts[i][0] - (a[0] + t * dx), pts[i][1] - (a[1] + t * dy));
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 at tolerance `tol` (0 = skip). */
function dpRing(ring, tol) {
if (tol <= 0 || ring.length < 4) 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 s = dpOpen(pts, tol);
if (s.length < 3) return ring;
return [...s, [...s[0]]];
}
/** Round (and optionally DP-simplify) every ring of a geometry. */
function transformGeom(geom, tol) {
const polys = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates)
.map((poly) => poly.map((ring) => roundRing(ring, R4)).map((ring) => (tol > 0 ? dpRing(ring, tol) : ring)))
.filter((poly) => poly.length >= 1 && poly.every((r) => r.length >= 3));
if (!polys.length) return null;
if (polys.length === 1 && geom.type === 'Polygon') return { type: 'Polygon', coordinates: polys[0] };
return { type: 'MultiPolygon', coordinates: polys };
}
// --- region record -------------------------------------------------------------
/**
* A region under management. `lonInt` is the tight longitude interval in
* [0,360)-derived unwrapped space (or `full` for a 360° band); `(clat, clon)`
* is the area-weighted centroid of the LARGEST outer ring (the region's
* "location"), unwrapped for its ring then wrapped to [-180,180) for
* cyclic-distance use.
*/
class Region {
constructor(f) {
this.id = f.id;
this.name = f.properties.name;
this.parent = f.properties.parent;
this.geom = f.geometry;
this.rawGeom = null; // last pre-normalization union output (safety net for build())
this.dead = false;
this.metrics();
}
metrics() {
this.area = geomAreaKm2(this.geom);
const polys = this.geom.type === 'Polygon' ? [this.geom.coordinates] : this.geom.coordinates;
let latMin = Infinity;
let latMax = -Infinity;
let lonA = Infinity;
let lonB = -Infinity;
let lonFull = false;
let bestRing = null;
let bestArea = -1;
for (const poly of polys) {
const outer = poly[0];
for (const [, la] of outer) {
if (la < latMin) latMin = la;
if (la > latMax) latMax = la;
}
const { lons, full } = unwrapRing(outer);
if (full) lonFull = true;
let a = Infinity;
let b = -Infinity;
for (const v of lons) {
if (v < a) a = v;
if (v > b) b = v;
}
lonA = Math.min(lonA, a);
lonB = Math.max(lonB, b);
const ar = ringAreaKm2(outer);
if (ar > bestArea) {
bestArea = ar;
bestRing = outer;
}
}
this.lonInt = { a: lonA, b: lonB, full: lonFull };
this.latMin = latMin;
this.latMax = latMax;
// area-weighted centroid of the largest outer ring, in unwrapped space
const { lons } = unwrapRing(bestRing);
const lats = bestRing.map((p) => p[1]);
const n = bestRing.length;
let A = 0;
let cx = 0;
let cy = 0;
for (let i = 0; i < n; i++) {
const x1 = lons[i];
const y1 = lats[i];
const x2 = lons[(i + 1) % n];
const y2 = lats[(i + 1) % n];
const f = x1 * y2 - x2 * y1;
A += f;
cx += (x1 + x2) * f;
cy += (y1 + y2) * f;
}
A /= 2;
if (Math.abs(A) > 1e-9) {
cx /= 6 * A;
cy /= 6 * A;
} else {
cx = (lonA + lonB) / 2;
cy = (latMin + latMax) / 2;
}
this.clon = (((cx + 180) % 360) + 360) % 360 - 180;
this.clat = cy;
}
/** Cyclic planar distance (km) to another region's centroid. */
distKm(o) {
let dlon = Math.abs(this.clon - o.clon) % 360;
if (dlon > 180) dlon = 360 - dlon;
const midLat = rad((this.clat + o.clat) / 2);
return Math.hypot(dlon * KM_LON * Math.cos(midLat), (this.clat - o.clat) * KM_LAT);
}
/** Bounding-box closeness to another same-space region (see CLOSE_DEG). */
closeTo(o) {
const latGap = this.latMax < o.latMin ? o.latMin - this.latMax : o.latMax < this.latMin ? this.latMin - o.latMax : 0;
if (latGap > CLOSE_DEG) return false;
return lonGap(this.lonInt, o.lonInt) <= CLOSE_DEG;
}
}
/**
* A "degenerate" region is a zero-area artifact: after 4-dp rounding its only
* ring has <3 distinct points (a line/point, e.g. a micro-municipality that
* v0.13's Douglas–Peucker collapsed to `[A, B, A]`). It contributes no area,
* so it can never be dissolved by the clipper (which returns null) nor kept
* through ring-dropping (which loses the 2-point ring). Merging one is purely
* an absorption: the host keeps its own geometry.
*/
const isDegenerate = (r) => r.area < SLIVER_EPS_KM2;
// --- main ----------------------------------------------------------------------
function main() {
const fc0 = JSON.parse(readFileSync(FILE, 'utf8'));
const regions = fc0.features.map((f) => new Region(f));
const byParent = new Map();
for (const r of regions) {
if (!byParent.has(r.parent)) byParent.set(r.parent, []);
byParent.get(r.parent).push(r);
}
const t0 = Date.now();
console.log(`input: ${fc0.features.length} regions across ${byParent.size} countries (${(Buffer.byteLength(readFileSync(FILE)) / 1024).toFixed(0)} KB)`);
console.log(`parameters: floor ${AREA_FLOOR_KM2.toLocaleString('en-US')} km2, close ${CLOSE_DEG} deg, max ${MAX_ITER} passes`);
const merges = []; // { from, fromName, fromArea, to, toName, toAreaAfter }
let fallbackUnions = 0;
// The fixed-point loop. Each pass: every region that is small AND has a
// close same-country neighbor merges into the nearest such neighbor
// (nearest by centroid distance; tie → larger area → lexicographic id).
for (let pass = 0; pass < MAX_ITER; pass++) {
let skippedIsland = 0;
let skippedGrew = 0;
const items = [];
for (const r of regions) {
if (r.dead || r.area >= AREA_FLOOR_KM2) continue;
const siblings = byParent.get(r.parent);
for (const s of siblings) {
if (s !== r && !s.dead && r.closeTo(s)) {
items.push(r);
break;
}
}
}
if (!items.length) {
console.log(`after pass ${pass}: fixed point reached (no small non-island regions left)`);
break;
}
items.sort((a, b) => (a.area !== b.area ? a.area - b.area : a.id < b.id ? -1 : 1));
let merged = 0;
for (const r of items) {
if (r.dead) continue;
if (r.area >= AREA_FLOOR_KM2) {
skippedGrew++;
continue; // absorbed a smaller region earlier this pass and crossed the floor
}
const siblings = byParent.get(r.parent);
// Close same-country neighbors as of NOW (mid-pass): some may have died
// earlier in this pass.
const close = [];
for (const s of siblings) if (s !== r && !s.dead && r.closeTo(s)) close.push(s);
if (!close.length) {
skippedIsland++;
continue; // lost its last close neighbor this pass → island now → exempt
}
// Prefer a REAL (non-degenerate) host so real regions dissolve cleanly;
// fall back to any close neighbor only when no real one is nearby.
const realCands = close.filter((s) => !isDegenerate(s));
const pool = realCands.length ? realCands : close;
let host = null;
for (const s of pool) {
if (
!host ||
r.distKm(s) < r.distKm(host) ||
(r.distKm(s) === r.distKm(host) &&
(s.area !== host.area ? s.area > host.area : s.id < host.id))
)
host = s;
}
if (isDegenerate(r)) {
// r is a zero-area artifact (line/point): it contributes no area, so the
// merge is a pure absorption — the host keeps its own geometry (a real
// host is untouched; if the host is also degenerate a cluster of dots
// collapses to one). Never route degenerate input through the clipper
// (returns null) or ring-dropping (would lose the 2-point ring the map
// already renders).
r.dead = true;
merged++;
merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area });
continue;
}
if (isDegenerate(host)) {
// r is real but the chosen host is a zero-area artifact: the host takes
// on r's geometry (keeping its own id/name). No clipper needed.
host.geom = r.geom;
host.rawGeom = r.geom;
host.metrics();
r.dead = true;
merged++;
merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area });
continue;
}
// Both real: dissolve via a boolean union; the host keeps its id/name
// and the item vanishes. @turf/union@7 dispatches through geomEach, which
// only understands a FeatureCollection / single Feature / bare geometry —
// a raw array of features has no `.type` and throws "Unknown Geometry
// Type". Wrap it.
let unionGeom = null;
let rawUnion = null;
try {
const u = union(featureCollection([feature(r.geom), feature(host.geom)]));
rawUnion = u ? u.geometry : null;
unionGeom = normalizeGeom(rawUnion);
} catch (err) {
console.error(` [warn] union failed for ${r.id} into ${host.id} (${err.message}); combining without dissolve`);
fallbackUnions++;
}
if (!unionGeom) {
// fallback: no dissolve — keep both shapes in one MultiPolygon
const polys = [...(r.geom.type === 'Polygon' ? [r.geom.coordinates] : r.geom.coordinates), ...(host.geom.type === 'Polygon' ? [host.geom.coordinates] : host.geom.coordinates)];
rawUnion = { type: 'MultiPolygon', coordinates: polys };
unionGeom = normalizeGeom(rawUnion);
if (!unionGeom) fallbackUnions++;
}
if (!unionGeom) {
console.error(` [error] could not merge ${r.id} into ${host.id}; leaving ${r.id} as-is`);
continue;
}
host.geom = unionGeom;
host.rawGeom = rawUnion;
host.metrics();
r.dead = true;
merged++;
merges.push({ from: r.id, fromName: r.name, fromArea: r.area, to: host.id, toName: host.name, toAreaAfter: host.area });
}
const alive = regions.filter((r) => !r.dead).length;
console.log(
`pass ${pass + 1}: ${items.length} small non-island region(s) → ${merged} merged` +
` (${skippedGrew} crossed the floor, ${skippedIsland} became island) → ${alive} regions remain`,
);
if (merged === 0) {
// items exist but nothing could merge (all became islands this pass) → stable
console.log(`pass ${pass + 1}: no merges possible — stable state`);
break;
}
}
// --- assemble output ----------------------------------------------------------
const alive = regions.filter((r) => !r.dead);
const sortById = (a, b) =>
a.parent !== b.parent ? (a.parent < b.parent ? -1 : 1) : a.id.localeCompare(b.id, 'en', { numeric: true });
alive.sort(sortById);
const build = (tol) => {
const feats = [];
for (const r of alive) {
// Never drop a region: if the simplified/normalized geometry degenerated,
// fall back to the raw (pre-normalization) union output, then to the
// untouched input geometry.
const g =
transformGeom(r.geom, tol) ??
(r.rawGeom ? transformGeom(r.rawGeom, 0) ?? r.rawGeom : r.geom);
feats.push({ type: 'Feature', id: r.id, properties: { name: r.name, parent: r.parent }, geometry: g });
}
return { type: 'FeatureCollection', features: feats };
};
let out = build(0);
let json = JSON.stringify(out);
let sizeKB = Buffer.byteLength(json) / 1024;
console.log(`\ncoarsened: ${fc0.features.length} → ${out.features.length} regions (${fc0.features.length - out.features.length} merged away)`);
let usedTol = 0;
if (sizeKB > SIZE_GUARD_KB) {
for (const t of DP_STEPS) {
out = build(t);
json = JSON.stringify(out);
sizeKB = Buffer.byteLength(json) / 1024;
usedTol = t;
console.log(`DP ${t}: ${sizeKB.toFixed(1)} KB`);
if (sizeKB < SIZE_TARGET_KB) break;
}
}
writeFileSync(FILE, json + '\n');
// --- summary -------------------------------------------------------------------
const areaOf = new Map();
const counts = new Map();
let maxA = 0;
let maxR = null;
for (const f of out.features) {
const a = geomAreaKm2(f.geometry);
areaOf.set(f.id, a);
counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
if (a > maxA) {
maxA = a;
maxR = f;
}
}
const ccounts = [...counts.values()].sort((a, b) => a - b);
const median =
ccounts.length % 2 ? ccounts[(ccounts.length - 1) / 2] : (ccounts[ccounts.length / 2 - 1] + ccounts[ccounts.length / 2]) / 2;
const maxCountry = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
// islands = small regions with no close same-country neighbor in the FINAL set
const finalByParent = new Map();
for (const f of out.features) {
if (!finalByParent.has(f.properties.parent)) finalByParent.set(f.properties.parent, []);
finalByParent.get(f.properties.parent).push(f);
}
// rebuild final metrics (light: only need island flag on the < FLOOR set)
const finalRegions = out.features.map((f) => new Region(f));
const finalByParent2 = new Map();
for (const r of finalRegions) {
if (!finalByParent2.has(r.parent)) finalByParent2.set(r.parent, []);
finalByParent2.get(r.parent).push(r);
}
let smallIslands = 0;
let smallNonIslands = 0;
const islandSamples = [];
for (const r of finalRegions) {
if (r.area >= AREA_FLOOR_KM2) continue;
let hasNeighbor = false;
for (const s of finalByParent2.get(r.parent)) {
if (s !== r && r.closeTo(s)) {
hasNeighbor = true;
break;
}
}
if (hasNeighbor) smallNonIslands++;
else {
smallIslands++;
if (islandSamples.length < 12) islandSamples.push(`${r.id} ${r.name} (${Math.round(r.area)} km2)`);
}
}
console.log(`wrote ${FILE} — ${sizeKB.toFixed(1)} KB (${(sizeKB / 1024).toFixed(2)} MB)` + (usedTol ? ` [DP @ ${usedTol}°]` : ''));
console.log(`per-country count: min ${ccounts[0]} / median ${median} / max ${ccounts[ccounts.length - 1]} (max: ${maxCountry[0]})`);
console.log(`max region: ${maxR.id} (${maxR.properties.name}) ≈ ${Math.round(maxA).toLocaleString('en-US')} km2`);
console.log(`small (<${AREA_FLOOR_KM2.toLocaleString('en-US')} km2) in final set: ${smallIslands + smallNonIslands} — ${smallIslands} islands (exempt) / ${smallNonIslands} non-island residuals`);
if (islandSamples.length) console.log(`island samples: ${islandSamples.join(', ')}`);
console.log(`fallback (non-dissolved) unions: ${fallbackUnions}`);
console.log(`merge log (${merges.length} merges): first 15:`);
for (const m of merges.slice(0, 15)) {
console.log(` ${m.from} (${m.fromName}, ${Math.round(m.fromArea)} km2) → ${m.to} (${m.toName}, now ${Math.round(m.toAreaAfter)} km2)`);
}
const logPath = '/tmp/opencode/coarsen-log.txt';
writeFileSync(logPath, merges.map((m) => `${m.from}\t${m.fromName}\t${Math.round(m.fromArea)}\t${m.to}\t${m.toName}\t${Math.round(m.toAreaAfter)}`).join('\n') + '\n');
console.log(`full merge log → ${logPath}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
main();
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/**
* Fill "no-one's-land" gaps left in `public/regions.geo.json` by the v0.14 coarsening merge.
*
* For each country, the gap = (country landmass) − (union of the country's sub-regions):
* area inside the country's authoritative landmass that no sub-region covers. Each gap
* fragment is assigned to the **nearest** sub-region (centroid haversine distance) and
* appended to it as extra polygon part(s). A fragment is only added if:
*
* • it is not a sub-tiny sliver (≥ GAP_MIN_KM2),
* • it does not fall inside an existing region's *hole* (water) — this keeps the
* Aral-basin hole in KZ-KZY open,
* • it is not byte-identical to a part the file already contains (polyclip sometimes
* re-emits a region's own outer ring with a phantom sliver hole; once the hole is
* cleaned the piece equals the existing part, and appending it would only grow the
* file on every re-run),
* • it has been clipped against every region of *other* countries (and against pieces
* already appended this run) that touch its bbox, so it never overlaps an existing
* region — no new cross-border overlaps are introduced.
*
* Holes found in gap fragments are kept only when they are real: pre-existing region
* holes (matched by centroid) or enclaves of another country (e.g. Lesotho inside ZAF,
* so the ZAF fragment stays a ring and never covers the LSO region); every other hole
* is a polyclip phantom and is dropped.
*
* Standard GeoJSON (`{type, coordinates}`) throughout — the same shape the file and
* MapLibre already use, and what @turf v7 expects.
*
* Usage: node scripts/fill-gaps.mjs [inFile [outFile]]
* inFile default: public/regions.geo.json
* outFile default: inFile (edit in place)
* Env: GAP_MIN_KM2 (default 25) — gap fragments below this area are ignored slivers.
*
* Deterministic: same input → same output. Re-running on an already-filled file is a
* no-op (every re-discovered fragment is either a sub-threshold sliver, a phantom that
* byte-matches an existing part, or already covered by the enlarged union), so the
* result is idempotent.
*/
import fs from "node:fs";
import difference from "@turf/difference";
import union from "@turf/union";
import centroid from "@turf/centroid";
import bbox from "@turf/bbox";
import area from "@turf/area";
import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
import distance from "@turf/distance";
import { feature, featureCollection, multiPolygon, polygon, point } from "@turf/helpers";
const GAP_MIN_KM2 = Number(process.env.GAP_MIN_KM2 ?? 25);
const RING_MIN_KM2 = 1e-4; // drop degenerate (numerical-noise) rings
// --------------------------------------------------------------------------- helpers
/**
* Round coordinates in place to 6 decimals (file convention). Accepts either a single
* ring (array of positions) or a polygon part (array of rings) and detects the depth.
*/
function roundCoords(coords) {
const isRing = Array.isArray(coords[0]) && Array.isArray(coords[0][0]) ? false : true;
const rings = isRing ? [coords] : coords;
for (const ring of rings) for (const c of ring) {
c[0] = Number(c[0].toFixed(6));
c[1] = Number(c[1].toFixed(6));
}
return coords;
}
/** Area (km²) of a closed ring, 0 when degenerate/NaN. */
function ringKm2(ring) {
if (!isValidRing(ring)) return 0;
try { return area(polygon([ring])) * 1e-6; } catch { return 0; }
}
/** A valid GeoJSON ring: ≥4 closed positions, each [finiteNumber, finiteNumber]. */
function isValidRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return false;
for (const c of ring) {
if (!Array.isArray(c) || c.length < 2) return false;
if (typeof c[0] !== "number" || !Number.isFinite(c[0])) return false;
if (typeof c[1] !== "number" || !Number.isFinite(c[1])) return false;
}
return true;
}
// Module-level refs populated in main before use (see keepHole).
let preHoleCentroids = []; // centroids of holes present in the input file (kept verbatim)
let otherLandmasses = []; // { iso, feature } for every country landmass
/**
* Is this hole a "real" one that must be preserved? Kept when it is a pre-existing
* region hole (centroid match, e.g. the Aral) or lies inside another country's
* landmass (enclave, e.g. Lesotho). Otherwise it is a polyclip phantom → dropped.
*/
function keepHole(hole, currentIso) {
const hKm2 = ringKm2(hole);
if (!(hKm2 >= RING_MIN_KM2)) return false; // degenerate / NaN
const cm = centroid(polygon([hole])).geometry.coordinates;
if (preHoleCentroids.some((p) => distance(cm, p) < 10)) return true;
if (otherLandmasses.some((l) => l.iso !== currentIso && booleanPointInPolygon(point(cm), l.feature))) return true;
return false;
}
/**
* Clean a GeoJSON polygon part (`[outerRing, ...holeRings]`): round coordinates, keep
* only real holes (see keepHole). Returns the cleaned part, or null if the outer ring
* is degenerate.
*/
function cleanPolygonPart(part, currentIso) {
if (!Array.isArray(part) || part.length === 0) return null;
const outer = part[0];
if (!isValidRing(outer)) return null;
const outerKm2 = ringKm2(outer);
if (!(outerKm2 >= RING_MIN_KM2)) return null; // covers NaN too
const holes = part.slice(1).filter((h) => isValidRing(h) && keepHole(h, currentIso));
roundCoords([outer, ...holes]);
return [outer, ...holes];
}
/** Split a geometry (Polygon|MultiPolygon) into cleaned polygon parts. */
function partsOf(geom, iso) {
if (!geom) return [];
const raw = geom.type === "MultiPolygon" ? geom.coordinates : [geom.coordinates];
const out = [];
for (const p of raw) { const c = cleanPolygonPart(p, iso); if (c) out.push(c); }
return out;
}
/** Rebuild a geometry (NOT a feature) from cleaned parts. */
function toGeometry(parts) {
// turf helpers return Features; take their .geometry ({type, coordinates}).
return parts.length > 1 ? multiPolygon(parts).geometry : polygon(parts[0]).geometry;
}
/** Bbox of a polygon part, as [west, south, east, north]. */
function partBBox(part) { return bbox(polygon(part)); }
function bBoxesOverlap(a, b) {
return !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
}
/** Sample points along a part's outer ring (start + every ~max(1, n/16)th vertex). */
function samplePoints(part, maxN = 16) {
const ring = part[0];
const n = ring.length;
if (n <= maxN) return ring;
const step = Math.ceil(n / maxN);
const out = [ring[0]];
for (let i = step; i < n; i += step) out.push(ring[i]);
return out;
}
// Module-level list of pre-existing region holes (water guard), built in main.
let holeRings = []; // { bbox, poly }
/** Does a gap piece overlap any pre-existing region hole (water)? */
function overlapsAnyHole(piece, pieceCm) {
const pBBox = partBBox(piece);
const candidates = holeRings.filter((h) => bBoxesOverlap(pBBox, h.bbox));
if (candidates.length === 0) return false;
const pts = [pieceCm, ...samplePoints(piece)];
return candidates.some((h) => pts.some((p) => booleanPointInPolygon(point(p), h.poly)));
}
// --------------------------------------------------------------------------- main
const inFile = process.argv[2] ?? "public/regions.geo.json";
const outFile = process.argv[3] ?? inFile;
const geo = JSON.parse(fs.readFileSync(inFile, "utf8"));
const countries = JSON.parse(fs.readFileSync(new URL("../public/countries.geo.json", import.meta.url), "utf8"));
otherLandmasses = countries.features.map((f) => ({ iso: f.id, feature: f }));
const features = geo.features;
const regionsByParent = new Map();
for (const f of features) {
const p = f.properties.parent;
if (!regionsByParent.has(p)) regionsByParent.set(p, []);
regionsByParent.get(p).push(f);
}
// Region inner-ring (hole) list for the water-hole guard; also the centroids of every
// pre-existing hole (so the hole policy never destroys one, e.g. KZ-KZY's Aral hole).
for (const f of features) {
const parts = f.geometry.type === "MultiPolygon" ? f.geometry.coordinates : [f.geometry.coordinates];
for (const p of parts) for (const h of p.slice(1)) {
if (Array.isArray(h) && h.length >= 4 && ringKm2(h) >= RING_MIN_KM2) {
const poly = polygon([h]);
holeRings.push({ bbox: bbox(poly), poly });
preHoleCentroids.push(centroid(poly).geometry.coordinates);
}
}
}
// Every part already in the input file (string key) — used to skip phantom pieces that
// re-emiss an existing part byte-for-byte.
const existingPartSet = new Set();
for (const f of features) {
const parts = f.geometry.type === "MultiPolygon" ? f.geometry.coordinates : [f.geometry.coordinates];
for (const p of parts) existingPartSet.add(JSON.stringify(p));
}
// All regions with bboxes, for cross-border clipping.
const allRegions = features.map((f) => ({
feature: f,
parent: f.properties.parent,
bbox: bbox(feature(f.geometry)),
}));
console.log(`input: ${inFile}`);
console.log(`regions: ${features.length} countries(with regions): ${regionsByParent.size} region holes: ${holeRings.length}`);
// Per-region running totals for the report.
const added = new Map(); // feature -> { km2, parts }
let totalSliverKm2 = 0, totalWaterKm2 = 0, totalFilledKm2 = 0, totalDupN = 0, totalClippedKm2 = 0, totalClipFailN = 0;
// Pieces already appended this run (any country) — later pieces clip against these so
// appended pieces never overlap each other either.
const appendedSoFar = []; // { parent, bbox, part }
for (const [iso, countryRegions] of regionsByParent) {
const landmass = countries.features.find((c) => c.id === iso);
if (!landmass) continue; // no authoritative landmass → nothing to fill against
const feats = countryRegions.map((r) => feature(r.geometry));
let unionGeom, diffGeom;
try {
// A country with a single sub-region has no union to compute: it IS the union.
unionGeom = countryRegions.length === 1
? countryRegions[0].geometry
: union(featureCollection(feats)).geometry;
diffGeom = difference(featureCollection([landmass, { ...landmass, geometry: unionGeom }])).geometry;
} catch (e) {
console.log(`${iso}\tSKIPPED (${unionGeom ? "difference" : "union"} failed: ${e.message})`);
continue;
}
if (!diffGeom) continue;
const rawPieces = partsOf(diffGeom, iso); // cleaned gap fragments inside the landmass
// Pass 1: filter (sliver / water / duplicate) and clip against foreign territory.
const fillable = [];
let sliverKm2 = 0, waterKm2 = 0, dupN = 0, clippedKm2 = 0, sliverN = 0, waterN = 0, clipFailN = 0;
for (const piece of rawPieces) {
const piecePoly = polygon(piece);
const aKm2 = area(piecePoly) * 1e-6;
if (aKm2 < GAP_MIN_KM2) { sliverKm2 += aKm2; sliverN++; continue; }
const pieceCm = centroid(piecePoly).geometry.coordinates;
if (overlapsAnyHole(piece, pieceCm)) { waterKm2 += aKm2; waterN++; continue; }
if (existingPartSet.has(JSON.stringify(piece))) { dupN++; continue; }
// Clip: remove any part of this piece that ANY existing region (own or foreign
// country) or an already-appended piece claims. Clipped *sequentially* against
// each touching target (more robust than one big union-difference): a target that
// fails to clip is skipped and counted, not allowed to abort the whole piece.
// Clipping against the country's OWN regions as well as foreign ones is what kills
// the same-country overlaps: the gap is `landmass − union(own regions)`, and
// polychip's difference can leave thin slivers of an own region inside that "gap";
// re-differencing each fragment against the individual regions (simpler operands
// than the big union) strips them out. Both operands are built as proper Features
// carrying a real geometry — never `feature()` on an already-Feature object (that
// double-wraps the geometry into the Feature itself and breaks polychip).
const pBBox = partBBox(piece);
const targets = [
...allRegions.filter((r) => bBoxesOverlap(r.bbox, pBBox)).map((r) => ({ feature: r.feature, bbox: r.bbox })),
...appendedSoFar.filter((a) => bBoxesOverlap(a.bbox, pBBox)).map((a) => ({ feature: polygon(a.part), bbox: a.bbox })),
];
if (targets.length === 0) {
fillable.push({ piece, aKm2, cm: pieceCm });
continue;
}
let cur = { type: "Feature", properties: {}, geometry: piecePoly.geometry };
let curBBox = pBBox;
let covered = false;
for (const t of targets) {
if (!bBoxesOverlap(curBBox, t.bbox)) continue;
let d;
try {
d = difference(featureCollection([cur, t.feature]));
} catch {
clipFailN++; // clip against this target failed: keep current (safe fallback)
continue;
}
if (!d || !d.geometry) { covered = true; break; } // piece fully covered by this target
cur = { type: "Feature", properties: {}, geometry: d.geometry };
curBBox = bbox(cur);
}
if (covered || !cur.geometry) { clippedKm2 += aKm2; continue; } // fully covered by foreign territory
const kept = [];
for (const cp of partsOf(cur.geometry, iso)) {
const cpArea = area(polygon(cp)) * 1e-6;
if (cpArea < GAP_MIN_KM2) { sliverKm2 += cpArea; sliverN++; continue; }
if (existingPartSet.has(JSON.stringify(cp))) { dupN++; continue; }
kept.push({ piece: cp, aKm2: cpArea, cm: centroid(polygon(cp)).geometry.coordinates });
}
clippedKm2 += Math.max(0, aKm2 - kept.reduce((s, k) => s + k.aKm2, 0));
fillable.push(...kept);
}
// Pass 2: nearest-region assignment and append.
const regionInfo = countryRegions.map((r) => {
const f = feature(r.geometry);
return { r, cm: centroid(f).geometry.coordinates, areaKm2: area(f) * 1e-6 };
});
let realKm2 = 0;
for (const { piece, aKm2, cm } of fillable) {
// Nearest region by centroid haversine distance; tie → larger area, then lexicographic id.
let best = null;
for (const info of regionInfo) {
const d = distance(cm, info.cm);
if (
best === null ||
d < best.d ||
(d === best.d && (info.areaKm2 > best.areaKm2 || (info.areaKm2 === best.areaKm2 && info.r.id < best.id)))
) best = { info, d };
}
if (!best) continue; // no regions at all (should not happen)
realKm2 += aKm2;
const target = best.info.r;
const parts = (target.geometry.type === "MultiPolygon" ? target.geometry.coordinates : [target.geometry.coordinates]).concat([piece]);
target.geometry = toGeometry(parts);
const a = added.get(target) ?? { km2: 0, parts: 0 };
a.km2 += aKm2; a.parts += 1;
added.set(target, a);
appendedSoFar.push({ parent: iso, bbox: partBBox(piece), part: piece });
}
totalSliverKm2 += sliverKm2;
totalWaterKm2 += waterKm2;
totalFilledKm2 += realKm2;
totalDupN += dupN;
totalClippedKm2 += clippedKm2;
totalClipFailN += clipFailN;
// Verify the fill for countries we actually changed: recompute the residual gap.
let afterKm2 = null;
if (realKm2 > 0) {
try {
const u2 = countryRegions.length === 1
? countryRegions[0].geometry
: union(featureCollection(countryRegions.map((r) => feature(r.geometry)))).geometry;
afterKm2 = area(difference(featureCollection([landmass, { ...landmass, geometry: u2 }]))) * 1e-6;
} catch { afterKm2 = null; }
}
console.log(
`${iso}\tregions=${countryRegions.length}\tgap-before=${(area(diffGeom) * 1e-6).toFixed(0)}` +
(afterKm2 === null ? "" : `\tgap-after=${afterKm2.toFixed(0)}`) +
`\tfilled=${realKm2.toFixed(0)}\tslivers=${sliverN}(${sliverKm2.toFixed(0)})\twater-skipped=${waterN}(${waterKm2.toFixed(0)})` +
`\tdup-skipped=${dupN}${clippedKm2 > 0 ? `\tforeign-overlap-removed=${clippedKm2.toFixed(0)}` : ""}${clipFailN > 0 ? `\tclip-failed=${clipFailN}` : ""}`,
);
}
const totalAddedKm2 = [...added.values()].reduce((s, a) => s + a.km2, 0);
const totalAddedParts = [...added.values()].reduce((s, a) => s + a.parts, 0);
console.log(`\nfilled ${totalAddedKm2.toFixed(0)} km² into ${added.size} regions (+${totalAddedParts} parts)`);
console.log(
`totals: filled=${totalFilledKm2.toFixed(0)} slivers-ignored=${totalSliverKm2.toFixed(0)} water-kept-open=${totalWaterKm2.toFixed(0)}` +
` duplicate-phantoms-skipped=${totalDupN} foreign-overlap-removed=${totalClippedKm2.toFixed(0)} clip-failed=${totalClipFailN}`,
);
fs.writeFileSync(outFile, JSON.stringify(geo));
console.log(`wrote ${outFile}`);
+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();
+570
View File
@@ -0,0 +1,570 @@
/**
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
*
* The dataset is REAL named sub-national administrative regions (Natural
* Earth admin-1: states, provinces, governorates, oblasts, …) that the
* coarsening step (`scripts/coarsen-regions.mjs`, v0.14) has merged down to a
* ~100×100 km (10,000 km²) floor: every small non-island region was dissolved
* into its nearest same-country neighbor, so the only regions left below the
* floor are islands (no same-country sibling nearby). 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`).
*
* v0.17 (`scripts/fill-gaps.mjs`) additionally fills the no-one's-land left
* between merged sub-regions: per country, the uncovered slivers of the
* country's landmass (≥ 25 km²) were added as extra parts of the nearest
* region, so the regions now cover the landmass almost completely (the
* sampling test below measures that per country).
*
* This test is self-contained (no import of the generator) and deliberately
* dependency-free — 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.
*
* No geojson/@turf types are imported: the project keeps `@types/geojson`
* OUT of the tsc program (see tsconfig `types`) so maplibre-gl's d.ts stays
* in its loose (skipLibCheck) mode; the one coverage test below uses an
* inline even-odd ray cast instead of a geometry library.
*
* 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;
}
/** Raw min/max bbox [w, s, e, n] of a geometry (all coordinates). */
function rawBBox(geom: Geometry): [number, number, number, number] {
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
for (const poly of polys)
for (const ring of poly)
for (const [lo, la] of ring) {
if (lo < w) w = lo;
if (lo > e) e = lo;
if (la < s) s = la;
if (la > n) n = la;
}
return [w, s, e, n];
}
/**
* Even-odd ray cast: is `[lon, lat]` inside `ring`? Planar, in raw lon/lat
* space (no antimeridian handling — the polygons and the sample points live
* in the same unwrapped space, so containment is mathematically consistent
* even for countries that cross the date line). Used only as a fast,
* deterministic coverage proxy (see the sampling test), never for areas.
*/
function pointInRing(pt: [number, number], ring: Ring): boolean {
const [x, y] = pt;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** Is a point inside a (Multi)Polygon: inside the outer ring, outside all holes. */
function pointInGeom(pt: [number, number], geom: Geometry): boolean {
if (geom.type === 'Polygon') {
if (!pointInRing(pt, geom.coordinates[0])) return false;
return geom.coordinates.slice(1).every((h) => !pointInRing(pt, h));
}
if (geom.type === 'MultiPolygon')
return geom.coordinates.some((p) => pointInGeom(pt, { type: 'Polygon', coordinates: p }));
return false;
}
// ------------------------------------------------------------------------------
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);
// Raw bboxes for the sampling test's cheap reject (O(total vertices)).
const regionBB = new Map<string, [number, number, number, number]>();
for (const f of regions.features) regionBB.set(f.id, rawBBox(f.geometry));
// --- island/close-neighbor helpers (mirror scripts/coarsen-regions.mjs) ------
interface LonInt {
a: number;
b: number;
full: boolean;
}
interface RegionBBox {
latMin: number;
latMax: number;
lon: LonInt;
}
const COARSE_FLOOR_KM2 = 10_000; // the coarsener's ~100×100 km floor
const CLOSE_DEG = 0.2; // "spatially close" bbox gap (both lat and lon ≤ this)
/**
* Unwrap a ring's longitudes to a continuous line (np.unwrap shortest-diff
* scheme, anchored at the first vertex's [0,360) value). `full` when the
* unwrapped span is ≥ 300° (a full-360° antimeridian band, e.g. Antarctica).
* Must stay in sync with the coarsening script.
*/
function unwrapRing(ring: Ring) {
const n = ring.length;
const norm = (x: number) => ((x % 360) + 360) % 360;
const u: number[] = new Array(n);
u[0] = norm(ring[0][0]);
for (let i = 1; i < n; i++) {
// the previous UNWRAPPED value may lie outside [0,360); normalize it
// before measuring the shortest signed difference, or a bogus ~180° jump
// is inserted and the ring's span balloons.
const a = norm(u[i - 1]);
const b = norm(ring[i][0]);
const d = (((b - a) % 360) + 540) % 360 - 180;
u[i] = u[i - 1] + d;
}
let min = Infinity;
let max = -Infinity;
for (const v of u) {
if (v < min) min = v;
if (v > max) max = v;
}
return { lons: u, full: max - min >= 300 };
}
/**
* Cyclic 1-D gap (degrees) between two tight longitude intervals in their own
* unwrapped spaces; a `full` interval is close to everything.
*/
function lonGap(intA: LonInt, intB: LonInt): number {
if (intA.full || intB.full) return 0;
let best = Infinity;
for (const k of [-1, 0, 1]) {
const a2 = intB.a + 360 * k;
const b2 = intB.b + 360 * k;
const gap = intA.b < a2 ? a2 - intA.b : b2 < intA.a ? intA.a - b2 : 0;
if (gap < best) best = gap;
}
return best;
}
// Per-region bbox in antimeridian-safe unwrapped longitude space.
const regionBBox = new Map<string, RegionBBox>();
for (const f of regions.features) {
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
let latMin = Infinity;
let latMax = -Infinity;
let a = Infinity;
let b = -Infinity;
let full = false;
for (const poly of polys) {
const outer = poly[0];
for (const [, la] of outer) {
if (la < latMin) latMin = la;
if (la > latMax) latMax = la;
}
const { lons, full: fl } = unwrapRing(outer);
if (fl) full = true;
for (const v of lons) {
if (v < a) a = v;
if (v > b) b = v;
}
}
regionBBox.set(f.id, { latMin, latMax, lon: { a, b, full } });
}
/** Bounding-box closeness (same definition the coarsening script uses). */
function bboxesClose(x: RegionBBox, y: RegionBBox): boolean {
const latGap = x.latMax < y.latMin ? y.latMin - x.latMax : y.latMax < x.latMin ? x.latMin - y.latMax : 0;
if (latGap > CLOSE_DEG) return false;
return lonGap(x.lon, y.lon) <= CLOSE_DEG;
}
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('leaves only islands below the ~10,000 km2 coarsening floor (non-island small regions merged)', () => {
// v0.14 coarsening: every region smaller than the floor that has a
// same-country sibling within CLOSE_DEG of its bbox is dissolved into its
// nearest such neighbor, so a below-floor region may only survive if it
// is an island. Count the non-island residuals below the floor and allow
// a small tolerance band (strictly < 1% of all regions) — the coarsener's
// fixed point leaves none, and this catches a regression in the merge
// logic (e.g. a broken neighbor test that exempts land regions) without
// being brittle to one or two borderline cases.
const smallNonIslands: string[] = [];
let smallIslands = 0;
let smallTotal = 0;
for (const f of regions.features) {
const a = regionArea.get(f.id) ?? 0;
if (a >= COARSE_FLOOR_KM2) continue;
smallTotal++;
const bb = regionBBox.get(f.id)!;
let hasNeighbor = false;
for (const g of regions.features) {
if (g.id === f.id || g.properties.parent !== f.properties.parent) continue;
if (bboxesClose(bb, regionBBox.get(g.id)!)) {
hasNeighbor = true;
break;
}
}
if (hasNeighbor) {
smallNonIslands.push(`${f.id} (${Math.round(a)} km2)`);
} else smallIslands++;
}
const frac = regions.features.length ? smallNonIslands.length / regions.features.length : 0;
expect(
frac < 0.01,
`${smallNonIslands.length} non-island region(s) below the ${COARSE_FLOOR_KM2} km2 floor ` +
`(${(100 * frac).toFixed(2)}% of all regions): ${smallNonIslands.slice(0, 10).join(', ')}`,
).toBe(true);
console.log(
`\n below-floor regions: ${smallTotal} — ${smallIslands} islands (exempt, no same-country neighbor within ${CLOSE_DEG}°)` +
` / ${smallNonIslands.length} non-island residuals (must stay < 1% of all regions)`,
);
});
it('leaves exactly one enclosed hole: KZ-KZY (the real Aral Sea), still large', () => {
// v0.15 filled every small hole; v0.16 smoothing never touches holes; and
// v0.17's gap-filler explicitly keeps the Aral hole (its centroid matches a
// pre-existing hole and it lies inside another country's landmass) while
// dropping phantom holes. So the whole file must contain exactly one hole,
// on KZ-KZY, at a realistic size (~5,860 km2 planar; the real Aral has
// shrunk to roughly this by the 2020s) — no new holes of any size.
let holes = 0;
let maxHole = 0;
const holeIds: string[] = [];
let kzkzyHoleArea = 0;
for (const f of regions.features) {
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
for (const poly of polys) {
for (let i = 1; i < poly.length; i++) {
holes++;
let lat = 0;
for (const [, la] of poly[i]) lat += la;
const area = ringAreaKm2(poly[i], poly[i].length ? lat / poly[i].length : 0);
if (area > maxHole) maxHole = area;
holeIds.push(f.id);
if (f.id === 'KZ-KZY') kzkzyHoleArea = area;
}
}
}
expect(holes, `expected exactly 1 hole (KZ-KZY), found ${holes}: ${[...new Set(holeIds)].join(', ')}`).toBe(1);
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
expect(
kzkzyHoleArea >= 5_000 && kzkzyHoleArea <= 7_000,
`KZ-KZY hole is ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2 — outside the 5,000–7,000 km2 Aral band`,
).toBe(true);
console.log(
`\n holes: exactly 1 (KZ-KZY ≈ ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2) — the Aral hole survived v0.17's fill`,
);
});
it('sums to within [40%, 190%] 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). v0.17's gap fill removed the large UNDER-fills (the old minimum
// was Brunei at ~52% — its regions now cover the landmass, ≈ 79%), but
// the OVER-fill tail is a pre-existing admin-1 artifact: a few tiny island
// nations (Vanuatu 178.9%, Solomon Is. 133%, Bahamas 129%) have admin-1
// polygons that extend beyond their own country polygon, and the fill
// only added area *inside* the country, so it can't shrink that tail.
// The band brackets the measured [78.8%, 178.9%] 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.9,
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 190%]`,
).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–190%)`,
);
});
it('covers each country\'s landmass with no significant no-one\'s-land (v0.17 gap fill, grid-sampled)', () => {
// The v0.17 fill (scripts/fill-gaps.mjs) assigned every uncovered sliver of
// each country's landmass (≥ 25 km²) to its nearest region, so the residual
// no-one's-land per country should be small everywhere. Measuring the
// exact union is too slow, so this samples a 25×25 grid over each
// country's raw bbox (skipping points outside the country polygon) and
// counts the fraction of in-land sample points that fall in NONE of the
// country's regions — a fast, deterministic proxy (same planar space,
// inline even-odd ray cast; antimeridian-safe because points and polygons
// share the raw coordinate space). Sampling over-counts coastline
// mismatch (admin-0 vs admin-1 edges), so the thresholds are generous:
// they catch a real regression (a whole sliver left unfilled) without
// being brittle to digitization fuzz.
const N = 25;
const results: { id: string; name: string; frac: number; land: number }[] = [];
for (const c of countries.features) {
const [w, s, e, n] = rawBBox(c.geometry);
if (e - w < 0.01 || n - s < 0.01) continue; // degenerate (point-like) country
const regs = regions.features.filter((f) => f.properties.parent === c.id);
let land = 0;
let uncov = 0;
for (let i = 0; i < N; i++) {
const la = s + ((i + 0.5) / N) * (n - s);
for (let j = 0; j < N; j++) {
const lo = w + ((j + 0.5) / N) * (e - w);
const pt: [number, number] = [lo, la];
if (!pointInGeom(pt, c.geometry)) continue;
land++;
// cheap bbox reject first, then the full containment test
if (!regs.some((f) => {
const [rw, rs, re, rn] = regionBB.get(f.id)!;
if (lo < rw || lo > re || la < rs || la > rn) return false;
return pointInGeom(pt, f.geometry);
}))
uncov++;
}
}
if (land >= 4) results.push({ id: c.id, name: c.properties.name, frac: uncov / land, land });
}
expect(results.length, `only ${results.length} countries sampled (expected ~176)`).toBeGreaterThanOrEqual(150);
const fracs = results.map((r) => r.frac).sort((a, b) => a - b);
const med = median(fracs);
const p90 = fracs[Math.floor(fracs.length * 0.9)];
const worst = results.reduce((m, r) => (r.frac > m.frac ? r : m));
const over20 = results.filter((r) => r.frac > 0.2).sort((a, b) => b.frac - a.frac);
expect(
med <= 0.05,
`median country gap ${(100 * med).toFixed(1)}% > 5% — significant no-one's-land remains`,
).toBe(true);
expect(
p90 <= 0.15,
`p90 country gap ${(100 * p90).toFixed(1)}% > 15% — many countries have real gaps`,
).toBe(true);
expect(
worst.frac <= 0.4,
`worst country ${worst.id} (${worst.name}) gap ${(100 * worst.frac).toFixed(1)}% > 40%`,
).toBe(true);
expect(
over20.length <= 3,
`${over20.length} countries with > 20% gap: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}`,
).toBe(true);
console.log(
`\n no-one's-land (grid-sampled, N=${N}): median ${(100 * med).toFixed(1)}% / p90 ${(100 * p90).toFixed(1)}%` +
` / max ${worst.id} (${worst.name}) ${(100 * worst.frac).toFixed(1)}%` +
(over20.length ? ` — >20%: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}` : ' — no country > 20%'),
);
});
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
// fixed point: it never moves an existing border, merges a part, or
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
// keeps the single KZ-KZY hole. The region count (1,796), the
// per-feature ids/names/parents and their order are unchanged; only the
// part/ring/vertex totals and the hole's part structure moved. Pinning
// the signature guards against a non-idempotent or destructive re-run.
// The fill is byte-deterministic per input and idempotent (a second run
// on the filled file changes nothing).
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
let verts = 0,
rings = 0,
parts = 0,
holes = 0;
const holeIds: string[] = [];
for (const f of regions.features) {
const polys = polysOf(f.geometry);
parts += polys.length;
for (const poly of polys) {
rings += poly.length;
holes += Math.max(0, poly.length - 1);
if (poly.length > 1) holeIds.push(f.id);
for (const ring of poly) verts += ring.length;
}
}
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175
console.log(
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
);
});
});
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* scripts/smooth-regions.mjs — smooths jagged sub-national region boundaries.
*
* Run: npm run smooth:regions (in place on public/regions.geo.json)
* node scripts/smooth-regions.mjs [out] [src]
*
* The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization
* left some sub-national boundaries overly jagged — the worst being coastal /
* borderland regions that the dissolve split into many small, zig-zag fragments
* (e.g. SI-048 Kocevje, Slovenia, 57 parts; MK-36 Kavadartsi, N. Macedonia, 8
* parts). This cleans the jaggedness in place with **Douglas–Peucker
* vertex-thinning** (`@turf/simplify`), applied to every ring of every polygon.
*
* DOUGLAS–PEUCKER — `@turf/simplify` keeps a vertex only when it deviates from
* the chord joining its kept neighbours by more than the tolerance, removing
* the redundant "in-between" points of a shallow zig-zag. The tolerance is
* expressed in the GeoJSON degree space (its distance metric treats 1° lon
* == 1° lat); TOLERANCE_M (metres) is converted with the same ~111 km/° the
* area model uses, so TOLERANCE_M = 50 is a gentle ~50 m pass. Bump it to
* 100 / 200 for a stronger pass and re-run — the pass is idempotent either way.
*
* VERTEX-THINNING ONLY — DP only removes points. It never moves a border,
* never merges/dissolves regions or parts, and never creates or removes a
* ring, so topology is preserved exactly: the region count, every `id`,
* `properties.name`/`properties.parent`, the part count of every feature, the
* single real hole (KZ-KZY), and all 83 islands are all unchanged. Only the
* redundant vertices along each ring are dropped.
*
* Deterministic + idempotent: one @turf/simplify pass at a fixed tolerance, then
* 4-dp rounding (the same convention as the other scripts). Because the output
* is already at the tolerance's fixed point, re-running on the smoothed file
* yields a byte-identical file (sha256-verified). The 18 degenerate 3-point
* micro-rings (the ~0-area "dots" like Ragged Island / the Paracels) are kept
* verbatim — @turf/simplify requires ≥4 points and would otherwise throw — so
* no ring is ever dropped and every feature keeps its exact part/ring count.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { simplify } from '@turf/simplify';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const DEFAULT_FILE = path.join(root, 'public', 'regions.geo.json');
const args = process.argv.slice(2).filter((a) => !a.startsWith('-'));
const OUT = args[0] ? path.resolve(args[0]) : DEFAULT_FILE;
const SRC = args[1] ? path.resolve(args[1]) : DEFAULT_FILE;
// --- parameters ---------------------------------------------------------------
const TOLERANCE_M = 50; // DP tolerance in metres — ~50 m is gentle (bump to 100/200 for more)
const M_PER_DEG = 111_000; // ~111 km per degree (same scale as the area model's 111.32/110.574)
const TOL_DEG = TOLERANCE_M / M_PER_DEG; // @turf/simplify works in degree space → ≈ 0.00045°
const R4 = 4; // coordinate precision (decimal places) — same as the other scripts
// --- rounding / normalization (same conventions as the other scripts) ----------
const rN = (v, N) => Math.round(v * 10 ** N) / 10 ** N;
function roundRing(ring, N = R4) {
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);
}
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 ring; // degenerate — keep verbatim, never corrupt a ring
return [...out, [...out[0]]];
}
// --- tiny geometry helpers -----------------------------------------------------
const polysOf = (g) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
const ringsOf = (g) => polysOf(g).flatMap((p) => p);
const countVerts = (g) => ringsOf(g).reduce((s, r) => s + r.length, 0);
const countParts = (g) => (g.type === 'Polygon' ? 1 : g.coordinates.length);
const countRings = (g) => ringsOf(g).length;
const countHoles = (g) => polysOf(g).reduce((s, p) => s + Math.max(0, p.length - 1), 0);
/**
* Smooth one closed ring with a single @turf/simplify (Douglas–Peucker) pass.
* Only removes vertices — never adds, never drops the ring. Degenerate rings
* (<4 pts) are returned verbatim (simplify would throw); a ring DP would
* over-collapse below a valid 4-pt ring is kept as-is; any error keeps the
* original. Never throws.
*/
function smoothRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return ring; // keep degenerate verbatim
try {
const feat = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } };
const next = simplify(feat, { tolerance: TOL_DEG }).geometry.coordinates[0];
if (!Array.isArray(next) || next.length < 4) return ring; // over-collapsed — keep original
const closed =
next[next.length - 1][0] === next[0][0] && next[next.length - 1][1] === next[0][1]
? next
: [...next, [next[0][0], next[0][1]]];
const rounded = roundRing(closed);
return rounded.length >= 4 ? rounded : ring;
} catch {
return ring;
}
}
/** Smooth every ring of a geometry. Never drops a ring or a part. */
function smoothGeom(geom) {
const newPolys = polysOf(geom).map((poly) => poly.map(smoothRing));
if (geom.type === 'Polygon' && newPolys.length === 1) return { type: 'Polygon', coordinates: newPolys[0] };
return { type: 'MultiPolygon', coordinates: newPolys };
}
// --- main ----------------------------------------------------------------------
function main() {
const t0 = Date.now();
const rawIn = readFileSync(SRC);
const fc = JSON.parse(rawIn);
const inBytes = Buffer.byteLength(rawIn);
console.log(`input: ${fc.features.length} regions (${(inBytes / 1024).toFixed(0)} KB) from ${SRC}`);
console.log(`mode: Douglas–Peucker via @turf/simplify, tolerance ${TOLERANCE_M} m (≈ ${TOL_DEG}°)`);
// before stats
let vb = 0, hb = 0, pb = 0, rb = 0, degenerate = 0;
for (const f of fc.features) {
vb += countVerts(f.geometry);
hb += countHoles(f.geometry);
pb += countParts(f.geometry);
rb += countRings(f.geometry);
for (const r of ringsOf(f.geometry)) if (r.length < 4) degenerate++;
}
const outFeatures = fc.features.map((f) => {
const g = smoothGeom(f.geometry);
return { type: 'Feature', id: f.id, properties: { name: f.properties.name, parent: f.properties.parent }, geometry: g };
});
const out = { type: 'FeatureCollection', features: outFeatures };
// after stats + change count (compare against the original, in order)
let va = 0, ha = 0, pa = 0, ra = 0, changed = 0;
for (let i = 0; i < out.features.length; i++) {
const f = out.features[i];
va += countVerts(f.geometry);
ha += countHoles(f.geometry);
pa += countParts(f.geometry);
ra += countRings(f.geometry);
if (JSON.stringify(f.geometry) !== JSON.stringify(fc.features[i].geometry)) changed++;
}
const afterVerts = new Map(out.features.map((f) => [f.id, countVerts(f.geometry)]));
const json = JSON.stringify(out);
writeFileSync(OUT, json + '\n');
const outBytes = Buffer.byteLength(json) + 1;
console.log(`\n--- RESULTS ---`);
console.log(`regions: ${fc.features.length} → ${out.features.length} (unchanged)`);
console.log(`vertices (incl. ring closure): ${vb} → ${va} removed ${vb - va} (${(100 * (vb - va) / vb).toFixed(2)}%)`);
console.log(`rings: ${rb} → ${ra} parts: ${pb} → ${pa} holes: ${hb} → ${ha} (all unchanged)`);
console.log(`regions whose geometry changed: ${changed} / ${fc.features.length}`);
console.log(`degenerate rings (<4 pts, kept verbatim): ${degenerate}`);
console.log(`file: ${(inBytes / 1024).toFixed(0)} KB → ${(outBytes / 1024).toFixed(0)} KB (${(outBytes / 1024 / 1024).toFixed(2)} MB)`);
// Diagnose the "messy" regions: the real problem is fragmentation — the
// dissolve left coherent regions as many disconnected parts. Rank by part
// count (then total vertices) so these surface; Kocelje/Kavadartsi are here.
const messy = fc.features
.map((f) => ({
id: f.id,
name: f.properties.name,
parent: f.properties.parent,
parts: countParts(f.geometry),
vb: countVerts(f.geometry),
}))
.sort((a, b) => b.parts - a.parts || b.vb - a.vb)
.slice(0, 15);
console.log(`\nMOST-MESSY (top ${messy.length} by fragmentation = parts, then vertices):`);
console.log(` id name country parts verts verts(after)`);
for (const m of messy)
console.log(` ${m.id.padEnd(10)} ${m.name.slice(0, 37).padEnd(38)} ${m.parent.padEnd(7)} ${String(m.parts).padStart(4)} ${String(m.vb).padStart(5)} ${String(afterVerts.get(m.id)).padStart(5)}`);
console.log(`\nwrote ${OUT}; elapsed ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
main();
+22
View File
@@ -21,6 +21,8 @@ function makeState(id: string): GameState {
}, },
}, },
events: [], events: [],
pending: [],
version: 1,
}; };
} }
@@ -53,6 +55,11 @@ describe('openDb', () => {
expect(db.load('a')?.events).toHaveLength(1); expect(db.load('a')?.events).toHaveLength(1);
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
// the monotonic version is persisted with the state and round-trips
s.version = 3;
db.save(s);
expect(db.load('a')?.version).toBe(3);
db.close(); db.close();
}); });
@@ -65,4 +72,19 @@ describe('openDb', () => {
expect(db.load('missing')).toBeNull(); expect(db.load('missing')).toBeNull();
db.close(); db.close();
}); });
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
db.save(makeState('b'));
expect(db.delete('nope')).toBe(false); // unknown id
expect(db.delete('a')).toBe(true); // removes a
expect(db.load('a')).toBeNull();
expect(db.load('b')?.id).toBe('b');
expect([...db.listIds()].sort()).toEqual(['b']);
expect(db.delete('a')).toBe(false); // already gone
db.close();
});
}); });
+11 -1
View File
@@ -3,11 +3,13 @@ import { mkdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
import { migrateTerritoryToRegions } from './regions';
export interface Db { export interface Db {
save(state: GameState): void; save(state: GameState): void;
load(id: string): GameState | null; load(id: string): GameState | null;
listIds(): string[]; listIds(): string[];
delete(id: string): boolean;
close(): void; close(): void;
} }
@@ -29,6 +31,7 @@ export function openDb(filePath: string): Db {
); );
const byId = db.prepare('SELECT data FROM games WHERE id = ?'); const byId = db.prepare('SELECT data FROM games WHERE id = ?');
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC'); const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
const del = db.prepare('DELETE FROM games WHERE id = ?');
return { return {
save(state) { save(state) {
upsert.run(state.id, JSON.stringify(state), new Date().toISOString()); upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
@@ -37,7 +40,11 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined; const row = byId.get(id) as { data: string } | undefined;
if (!row) return null; if (!row) return null;
try { try {
return JSON.parse(row.data) as GameState; // v0.19: migrate any pre-v0.19 (whole-country ISO_A3) territory to
// sub-region ids on read. A no-op when the region geo is not loaded,
// and idempotent once a state has been migrated (the next save then
// persists the region-level territory).
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
} catch { } catch {
return null; return null;
} }
@@ -45,6 +52,9 @@ export function openDb(filePath: string): Db {
listIds() { listIds() {
return (all.all() as { id: string }[]).map((r) => r.id); return (all.all() as { id: string }[]).map((r) => r.id);
}, },
delete(id) {
return del.run(id).changes > 0;
},
close() { close() {
db.close(); db.close();
}, },
+334 -16
View File
@@ -1,11 +1,17 @@
import { describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import type { GameState, Preset } from '../shared/types'; import type { GameState, Preset, RoundItem } from '../shared/types';
import type { TurnResult } from '../shared/protocol'; import { makeEvent, type TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm'; import type { LlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { _setRegionsForTests } from './regions';
import { import {
applyTurnResult, applyTurnResult,
buildSystemPrompt,
buildUserPrompt,
isMeaningful, isMeaningful,
parseTurnResult, parseTurnResult,
queueRoundItem,
removeRoundItem,
runTurn, runTurn,
validateTurnResult, validateTurnResult,
} from './engine'; } from './engine';
@@ -19,6 +25,27 @@ function fakeLlm(responses: string[]): LlmClient {
}; };
} }
// Give the region module a small, deterministic index so `buildUserPrompt`'s
// `regions` reference table is populated in these fast unit tests (the real
// 3.4 MB geo stays out of this file). The ids match the synthetic territories
// below.
beforeAll(() => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
'US-01': { name: 'New England', parent: 'USA' },
'US-02': { name: 'The Midwest', parent: 'USA' },
'PL-01': { name: 'Pomerania', parent: 'POL' },
'DE-01': { name: 'Rhineland', parent: 'DEU' },
},
{ GBR: ['GB-01', 'GB-02'], USA: ['US-01', 'US-02'], POL: ['PL-01'], DEU: ['DE-01'] },
);
});
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes. `us` also holds `PL-01` so that a canned or
// stubbed gain of that region has a prior owner to move it from.
const preset: Preset = { const preset: Preset = {
id: 'test', id: 'test',
title: 'Test', title: 'Test',
@@ -32,7 +59,7 @@ const preset: Preset = {
name: 'UK', name: 'UK',
type: 'major', type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 }, stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR'], territory: ['GB-01', 'GB-02'],
relations: { us: 'neutral', de: 'hostile' }, relations: { us: 'neutral', de: 'hostile' },
}, },
{ {
@@ -40,7 +67,7 @@ const preset: Preset = {
name: 'US', name: 'US',
type: 'major', type: 'major',
stats: { military: 60, economy: 90, stability: 80, population: 80 }, stats: { military: 60, economy: 90, stability: 80, population: 80 },
territory: ['USA'], territory: ['US-01', 'US-02', 'PL-01'],
relations: { uk: 'neutral' }, relations: { uk: 'neutral' },
}, },
{ {
@@ -48,7 +75,7 @@ const preset: Preset = {
name: 'DE', name: 'DE',
type: 'major', type: 'major',
stats: { military: 95, economy: 70, stability: 75, population: 90 }, stats: { military: 95, economy: 70, stability: 75, population: 90 },
territory: ['DEU'], territory: ['DE-01'],
relations: {}, relations: {},
}, },
], ],
@@ -68,6 +95,8 @@ function baseState(): GameState {
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } }, de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
}, },
events: [], events: [],
pending: [],
version: 1,
}; };
} }
@@ -81,28 +110,30 @@ const validResult = JSON.stringify({
{ nation: 'de', stat: 'economy', op: 'add', value: 10 }, { nation: 'de', stat: 'economy', op: 'add', value: 10 },
], ],
relations: [{ a: 'uk', b: 'us', set: 'ally' }], relations: [{ a: 'uk', b: 'us', set: 'ally' }],
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
}); });
describe('runTurn', () => { describe('runTurn', () => {
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => { it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date, pending cleared', async () => {
const original = baseState(); const original = baseState();
const { state, result } = await runTurn({ const { state, result } = await runTurn({
llm: fakeLlm([validResult]), llm: fakeLlm([validResult]),
preset, preset,
state: original, state: original,
action: 'We rearm the coast', items: [{ kind: 'action', text: 'We rearm the coast' }],
}); });
expect(state.turn).toBe(2); // the turn counter advanced expect(state.turn).toBe(2); // the turn counter advanced
expect(state.date).toBe('1939-09-03'); expect(state.date).toBe('1939-09-03');
expect(state.pending).toEqual([]); // committing a round always clears it
expect(state.nations.uk.stats.military).toBe(85); expect(state.nations.uk.stats.military).toBe(85);
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add) expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
// relations are symmetric // relations are symmetric
expect(state.nations.uk.relations.us).toBe('ally'); expect(state.nations.uk.relations.us).toBe('ally');
expect(state.nations.us.relations.uk).toBe('ally'); expect(state.nations.us.relations.uk).toBe('ally');
// territory moved to DE // the region moved to DE, out of its prior owner (us)
expect(state.nations.de.territory).toContain('POL'); expect(state.nations.de.territory).toContain('PL-01');
expect(state.nations.us.territory).not.toContain('PL-01');
// one event appended, tagged with the turn that was resolved (1) // one event appended, tagged with the turn that was resolved (1)
expect(state.events).toHaveLength(1); expect(state.events).toHaveLength(1);
expect(state.events[0].turn).toBe(1); expect(state.events[0].turn).toBe(1);
@@ -113,7 +144,8 @@ describe('runTurn', () => {
// and the original state object is NOT mutated // and the original state object is NOT mutated
expect(original.turn).toBe(1); expect(original.turn).toBe(1);
expect(original.nations.uk.stats.military).toBe(80); expect(original.nations.uk.stats.military).toBe(80);
expect(original.nations.de.territory).not.toContain('POL'); expect(original.nations.us.territory).toContain('PL-01');
expect(original.nations.de.territory).not.toContain('PL-01');
}); });
it('clamps out-of-range stat values into 0-100', async () => { it('clamps out-of-range stat values into 0-100', async () => {
@@ -131,7 +163,7 @@ describe('runTurn', () => {
llm: fakeLlm([clampResult]), llm: fakeLlm([clampResult]),
preset, preset,
state: baseState(), state: baseState(),
action: 'x', items: [],
}); });
expect(state.nations.uk.stats.military).toBe(100); expect(state.nations.uk.stats.military).toBe(100);
expect(state.nations.uk.stats.stability).toBe(0); expect(state.nations.uk.stats.stability).toBe(0);
@@ -142,7 +174,7 @@ describe('runTurn', () => {
llm: fakeLlm(['total garbage', 'still not json']), llm: fakeLlm(['total garbage', 'still not json']),
preset, preset,
state: baseState(), state: baseState(),
action: 'x', items: [],
}); });
expect(state.turn).toBe(2); expect(state.turn).toBe(2);
expect(state.events).toHaveLength(1); expect(state.events).toHaveLength(1);
@@ -162,7 +194,7 @@ describe('runTurn', () => {
llm: fakeLlm([emptyResult, emptyResult]), llm: fakeLlm([emptyResult, emptyResult]),
preset, preset,
state: baseState(), state: baseState(),
action: 'x', items: [],
}); });
expect(state.turn).toBe(2); expect(state.turn).toBe(2);
expect(state.events[0].title).toBe('The turn was unclear'); expect(state.events[0].title).toBe('The turn was unclear');
@@ -174,13 +206,240 @@ describe('runTurn', () => {
const { state: next } = await runTurn({ const { state: next } = await runTurn({
llm: fakeLlm([validResult]), llm: fakeLlm([validResult]),
preset, preset,
state, // no action / no chat = jump forward state, // no items = an empty round / jump forward
items: [],
}); });
expect(next.turn).toBe(4); expect(next.turn).toBe(4);
expect(next.events).toHaveLength(1); expect(next.events).toHaveLength(1);
}); });
}); });
describe('runTurn (batched round, deterministic stub)', () => {
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5), one deterministic region gain', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
const { state, result } = await runTurn({
llm: makeStubLlmClient(),
preset,
state: original,
items: original.pending,
});
expect(state.turn).toBe(2);
expect(state.date).toBe('1939-09-03');
expect(state.pending).toEqual([]);
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
expect(state.events).toHaveLength(1);
expect(state.events[0].type).toBe('military');
expect(state.events[0].nation).toBe('uk');
expect(state.events[0].outcome).toBe('success');
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
// the stub's one deterministic per-turn territory move: the player annexes
// the lexicographically-first region of the first other nation (us holds
// PL-01, US-01, US-02 → PL-01 wins), moved out of us.
expect(result.territoryChanges).toEqual([{ nation: 'uk', gain: ['PL-01'], lose: [] }]);
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
expect(state.nations.us.territory).toEqual(['US-01', 'US-02']);
// committing a turn is a new revision too
expect(state.version).toBe(2);
// the original object is not mutated (a fresh queue was consumed)
expect(original.pending).toHaveLength(1);
expect(original.version).toBe(1);
});
it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => {
const { state } = await runTurn({
llm: makeStubLlmClient(),
preset,
state: baseState(), // pending: []
items: [],
});
expect(state.turn).toBe(2);
expect(state.date).toBe('1939-09-03');
expect(state.pending).toEqual([]);
expect(state.events).toHaveLength(1);
expect(state.events[0].type).toBe('political');
expect(state.events[0].title).toBe('The world moves on');
expect(state.events[0].outcome).toBe('neutral');
// an empty round makes no move: every territory is untouched
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
expect(state.nations.us.territory).toEqual(['US-01', 'US-02', 'PL-01']);
expect(state.nations.de.territory).toEqual(['DE-01']);
});
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', async () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
{ kind: 'action', text: 'Expand the shipyards' },
{ kind: 'diplomacy', to: 'us', text: 'Propose a mutual defense pact' },
];
const { state: next } = await runTurn({
llm: makeStubLlmClient(),
preset,
state,
items: state.pending,
});
expect(next.turn).toBe(2); // a whole batch = one turn
expect(next.pending).toEqual([]);
expect(next.events).toHaveLength(3);
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
// …and one (not three) territory move for the whole batch
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
});
});
describe('buildUserPrompt (batched round)', () => {
it('includes every queued item (action + diplomacy) in the emitted payload', () => {
const items: RoundItem[] = [
{ kind: 'action', text: 'Fortify Dover' },
{ kind: 'diplomacy', to: 'us', text: 'Propose a pact' },
];
const parsed = JSON.parse(buildUserPrompt(baseState(), items)) as {
pending: RoundItem[];
playerActions: unknown[];
roundInstruction: string;
regions: Record<string, string>;
};
expect(parsed.pending).toEqual(items);
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
expect(parsed.roundInstruction).toContain('Fortify Dover');
expect(parsed.roundInstruction).toContain('Propose a pact');
});
it('emits a regions reference table ("id → name (parent)") covering every territory id in the state', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
regions: Record<string, string>;
state: { nations: Record<string, { territory: string[] }> };
};
// the table is keyed by the union of all nations' (sub-region) territory ids
const allIds = Object.values(parsed.state.nations).flatMap((n) => n.territory);
expect(Object.keys(parsed.regions).sort()).toEqual([...allIds].sort());
// each id maps to a human "name (parent)" label from the region index
expect(parsed.regions['GB-01']).toBe('Greater London (GBR)');
expect(parsed.regions['DE-01']).toBe('Rhineland (DEU)');
expect(parsed.regions['PL-01']).toBe('Pomerania (POL)');
});
it('frames an empty round as "the world advances on its own momentum"', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
pending: RoundItem[];
roundInstruction: string;
};
expect(parsed.pending).toEqual([]);
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', () => {
it('appends an item to pending without advancing the turn or the date', () => {
const before = baseState();
const after = queueRoundItem(before, { kind: 'diplomacy', to: 'us', text: 'hello' });
expect(after.turn).toBe(before.turn);
expect(after.date).toBe(before.date);
expect(after.pending).toHaveLength(1);
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
// the original state is not mutated
expect(before.pending).toHaveLength(0);
expect(before.version).toBe(1);
});
it('bumps the monotonic version on each queue mutation', () => {
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
expect(a.version).toBe(2);
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
expect(b.version).toBe(3);
// and never backwards: two consecutive adds from the same base both
// advance past it, so a late snapshot of the older state is always older.
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
expect(b2.version).toBe(2);
expect(b.version).toBeGreaterThan(b2.version);
});
it('treats a legacy state without a version field as version 0', () => {
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
delete legacy.version; // a row saved before the version field existed
// simulate a legacy row coming back from SQLite: the JSON has no field.
const after = queueRoundItem(legacy as unknown as GameState, {
kind: 'action',
text: 'first after upgrade',
});
expect(after.version).toBe(1);
});
});
describe('removeRoundItem', () => {
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'one' },
{ kind: 'diplomacy', to: 'us', text: 'two' },
{ kind: 'action', text: 'three' },
];
const next = removeRoundItem(state, 1);
expect(next).not.toBeNull();
expect(next!.version).toBe(2);
expect(next!.pending).toEqual([
{ kind: 'action', text: 'one' },
{ kind: 'action', text: 'three' },
]);
expect(state.pending).toHaveLength(3); // original untouched
expect(state.version).toBe(1);
});
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'one' }];
expect(removeRoundItem(state, -1)).toBeNull();
expect(removeRoundItem(state, 1)).toBeNull(); // == length
expect(removeRoundItem(state, 5)).toBeNull();
expect(removeRoundItem(state, 0.5)).toBeNull();
expect(removeRoundItem(state, Number.NaN)).toBeNull();
expect(state.pending).toHaveLength(1); // still untouched
});
});
describe('parseTurnResult', () => { describe('parseTurnResult', () => {
it('parses a fenced JSON block', () => { it('parses a fenced JSON block', () => {
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```'); const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
@@ -237,6 +496,65 @@ describe('applyTurnResult', () => {
applyTurnResult(state, result); applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
}); });
it('clears the pending round on commit and bumps the version', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'x' }];
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
expect(next.version).toBe(2);
expect(state.pending).toHaveLength(1); // original untouched
expect(state.version).toBe(1);
});
it('moves a gained region out of its prior owner (region-level transfer)', () => {
const state = baseState();
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
};
const next = applyTurnResult(state, result);
expect(next.nations.de.territory).toContain('PL-01');
expect(next.nations.us.territory).not.toContain('PL-01'); // spliced out of the prior owner
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
});
it('adds a region nobody owned (no prior owner to splice from)', () => {
const state = baseState();
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'uk', gain: ['FR-57'], lose: [] }],
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'FR-57']);
});
it('drops a lost region with no gainer (it becomes unowned / no-man\'s-land)', () => {
const state = baseState();
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'us', gain: [], lose: ['PL-01'] }],
};
const next = applyTurnResult(state, result);
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
expect(Object.values(next.nations).every((n) => !n.territory.includes('PL-01'))).toBe(true);
});
}); });
describe('isMeaningful', () => { describe('isMeaningful', () => {
+85 -23
View File
@@ -9,6 +9,7 @@ import type {
GameState, GameState,
Preset, Preset,
RelationStatus, RelationStatus,
RoundItem,
StatKey, StatKey,
} from '../shared/types'; } from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types'; import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
@@ -23,6 +24,7 @@ import {
} from '../shared/protocol'; } from '../shared/protocol';
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm'; import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
import { ADVISOR_MARKER } from './stub'; import { ADVISOR_MARKER } from './stub';
import { regionNamesFor } from './regions';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES); const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES); const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
@@ -52,24 +54,42 @@ export function buildSystemPrompt(preset: Preset): string {
' ],', ' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],', ' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],', ' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]', ' "territoryChanges": [{"nation":"<nationId>","gain":["<regionId>"],"lose":["<regionId>"]}]',
'}', '}',
'', '',
'HARD CONSTRAINTS', 'HARD CONSTRAINTS',
'- Use only nation ids and ISO_A3 codes that exist in the provided state.', '- Use only nation ids and sub-region ids (e.g. `FR-57`) that exist in the provided state.',
'- A region id (e.g. `FR-57`) identifies a single sub-region — the `regions` table below lists each id with its name and country; a nation\'s border can change one region at a time.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.', '- Keep every stat value between 0 and 100.',
'- Only change borders when war or a decisive political shift justifies it.', '- Only change borders when war or a decisive political shift justifies it.',
'- If a field is not relevant this turn, set it to an empty array.', '- If a field is not relevant this turn, set it to an empty array.',
].join('\n'); ].join('\n');
} }
/** The user message: a compact JSON snapshot + the player's action/chat. */ /**
export function buildUserPrompt( * The user message: a compact JSON snapshot of the world plus the player's
state: GameState, * queued round (`items`) — the free-text actions and diplomatic messages to be
action?: string, * simulated this period. `playerActions` is an LLM-friendly rendering of each
chat?: { to: string; message: string }, * item; the raw `pending` list is also emitted so the deterministic stub (and any
): string { * other consumer) can read the exact items. The framing instruction tells the
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
*/
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
const playerActions = items.map((i) =>
i.kind === 'action' ? i.text : { to: i.to ?? '', message: i.text },
);
const roundInstruction =
items.length > 0
? `This period the player (${playerName}) takes the following actions and sends the following diplomatic messages: ${JSON.stringify(
playerActions,
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
const payload = { const payload = {
state: { state: {
turn: state.turn, turn: state.turn,
@@ -78,7 +98,7 @@ export function buildUserPrompt(
nations: Object.fromEntries( nations: Object.fromEntries(
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]), Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
), ),
recentEvents: state.events.slice(-10).map((e) => ({ recentEvents: state.events.slice(-5).map((e) => ({
turn: e.turn, turn: e.turn,
date: e.date, date: e.date,
nation: e.nation, nation: e.nation,
@@ -88,8 +108,18 @@ export function buildUserPrompt(
outcome: e.outcome, outcome: e.outcome,
})), })),
}, },
action: action ?? null, // A name lookup for every region id referenced anywhere in the state, so the
chat: chat ?? null, // model can map an id it is considering (e.g. `FR-57`) to a place it
// recognizes. This is a top-level reference table keyed by sub-region id;
// each nation's own `territory` still carries raw ids (via nationForPrompt,
// unchanged) so the deterministic stub can parse them. When the region geo
// is not loaded the table is empty and the model falls back to the raw ids.
regions: regionNamesFor(
Object.values(state.nations).flatMap((n) => n.territory),
),
playerActions,
pending: items,
roundInstruction,
}; };
return JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
} }
@@ -266,9 +296,12 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
*/ */
export function applyTurnResult(state: GameState, result: TurnResult): GameState { export function applyTurnResult(state: GameState, result: TurnResult): GameState {
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2); next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
// Committing a turn consumes the whole queued round — reset it for the next.
next.pending = [];
for (const ev of result.events) { for (const ev of result.events) {
next.events.push(makeEvent(ev, actingTurn, next.date)); next.events.push(makeEvent(ev, actingTurn, next.date));
} }
@@ -302,22 +335,55 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
return next; return next;
} }
/**
* Immutably queue one item onto a round: append it to `pending` and return a new
* state. This never advances the turn or the date — that only happens when a
* round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
* snapshots can never clobber it client-side). Exported so the append-without-
* advance behavior is unit-testable, and reused by the /action and /diplomacy
* routes.
*/
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pending.push(item);
return next;
}
/**
* Immutably remove one item from the pending round by index and return a new
* state (bumping `version`). Returns `null` when the index is missing or not a
* valid, in-bounds integer position, so the route can answer 400 without
* mutating anything. Reused by the server's /queue/remove route.
*/
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
return null;
}
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pending.splice(index, 1);
return next;
}
/** /**
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the * Run one turn end-to-end against the (injectable) LLM: build prompts, call the
* model, parse/validate, retry once with a nudge if invalid, and — if it is still * model ONCE over the whole pending batch, parse/validate, retry once with a
* invalid — emit a generic "unclear" event and advance. Never throws on bad * nudge if the result is empty/invalid, and — if it is still invalid — emit a
* model output; only the underlying transport/network can throw. * generic "unclear" event and advance. The committed state has `pending` cleared.
* Never throws on bad model output; only the underlying transport/network can throw.
*/ */
export async function runTurn(opts: { export async function runTurn(opts: {
llm: LlmClient; llm: LlmClient;
preset: Preset; preset: Preset;
state: GameState; state: GameState;
action?: string; items: RoundItem[];
chat?: { to: string; message: string };
}): Promise<{ state: GameState; result: TurnResult }> { }): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, action, chat } = opts; const { llm, preset, state, items } = opts;
const system = buildSystemPrompt(preset); const system = buildSystemPrompt(preset);
const user = buildUserPrompt(state, action, chat); const user = buildUserPrompt(state, items);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
let result = parseTurnResult(await llm.chat([ let result = parseTurnResult(await llm.chat([
{ role: 'system', content: system }, { role: 'system', content: system },
@@ -329,11 +395,7 @@ export async function runTurn(opts: {
{ role: 'system', content: system }, { role: 'system', content: system },
{ role: 'user', content: user }, { role: 'user', content: user },
{ role: 'assistant', content: '' }, { role: 'assistant', content: '' },
{ { role: 'user', content: nudge },
role: 'user',
content:
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
},
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS })); ], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
} }
+94 -36
View File
@@ -9,11 +9,17 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol'; import { deepClone } from '../shared/protocol';
import type { CreateGameRequest, TurnRequest } from '../shared/protocol'; import type {
AddActionRequest,
AddDiplomacyRequest,
CreateGameRequest,
RemoveQueueItemRequest,
} from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
import { createLlmClient } from './llm'; import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub'; import { makeStubLlmClient } from './stub';
import { askAdvisor, runTurn } from './engine'; import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { expandTerritory, loadRegions } from './regions';
import { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { createPresetStore } from './presets'; import { createPresetStore } from './presets';
import { openDb, type Db } from './db'; import { openDb, type Db } from './db';
@@ -36,6 +42,10 @@ function findRoot(): string {
} }
const root = findRoot(); const root = findRoot();
// Load the sub-region geo once at boot (a graceful no-op, with a warning, when
// the file is missing — the territory model then degrades to whole-country
// ISO_A3 codes). Must run before the first game creation/load.
loadRegions(root);
const presetsDir = path.join(root, 'presets'); const presetsDir = path.join(root, 'presets');
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist'); const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data'); const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
@@ -111,8 +121,19 @@ app.post('/api/games', (req: Request, res) => {
playerNationId: nation.id, playerNationId: nation.id,
turn: 1, turn: 1,
date: preset.startDate, date: preset.startDate,
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])), // v0.19: territory is at sub-region granularity — expand each preset
// nation's ISO_A3 country codes into all their sub-region ids (a no-op that
// keeps the country codes when the region geo is not loaded).
nations: Object.fromEntries(
preset.nations.map((n) => {
const nation = deepClone(n);
nation.territory = expandTerritory(nation.territory);
return [n.id, nation];
}),
),
events: [], events: [],
pending: [],
version: 1, // the first saved revision; every later mutation bumps it
}; };
db.save(state); db.save(state);
res.json({ game: state }); res.json({ game: state });
@@ -149,46 +170,83 @@ app.get('/api/games/:id/map', (req, res) => {
res.json({ regions: buildRegionMap(game) }); res.json({ regions: buildRegionMap(game) });
}); });
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> { // Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on
const game = db.load(id); // success broadcast an SSE `error` to any other open tab watching it so it
if (!game) throw new Error('not_found'); // returns home (see the `event: error` handling in src/lib/sse.ts).
const preset = presetStore.get(game.presetId); app.delete('/api/games/:id', (req, res) => {
if (!preset) throw new Error('preset_missing'); const id = req.params.id;
const { state, result } = await runTurn({ const removed = db.delete(id);
llm, if (!removed) return res.status(404).json({ error: 'game not found' });
preset, broadcast(id, 'error', { message: 'game deleted' });
state: game, res.json({ deleted: true });
action: body?.action,
chat: body?.chat,
});
db.save(state);
broadcast(id, 'turn', { state, result });
return { state, result };
}
app.post('/api/games/:id/turn', async (req, res) => {
try {
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `turn failed: ${msg}` });
}
}); });
// 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) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const text = (req.body as AddActionRequest | undefined)?.text?.trim();
if (!text) return res.status(400).json({ error: 'empty action' });
const next = queueRoundItem(game, { kind: 'action', text });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Queue a diplomatic message to a target nation onto the pending round. No LLM
// call and no advance.
app.post('/api/games/:id/diplomacy', (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const body = (req.body ?? {}) as AddDiplomacyRequest;
const message = body.message?.trim();
if (!body.to || !message) return res.status(400).json({ error: 'empty message' });
const next = queueRoundItem(game, { kind: 'diplomacy', to: body.to, text: message });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Remove one item from the pending round, by its index in `game.pending`. No
// LLM call, no turn advance — same fast save → broadcast → respond order as the
// add routes above. The mutated state bumps `version`, so clients (which apply
// states "never go backwards") always reconcile to it.
app.post('/api/games/:id/queue/remove', (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const index = (req.body as RemoveQueueItemRequest | undefined)?.index;
const next = removeRoundItem(game, index);
if (!next) return res.status(400).json({ error: 'invalid queue index' });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Jump forward: commit the whole pending round (or an empty one) to the model
// in a single turn. applyTurnResult clears the pending queue on the returned
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
app.post('/api/games/:id/jump', async (req, res) => { app.post('/api/games/:id/jump', async (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const preset = presetStore.get(game.presetId);
if (!preset) return res.status(404).json({ error: 'preset_missing' });
try { try {
// Jump forward = a turn with no player action (auto-simulate the world). const { state, result } = await runTurn({
const { state, result } = await handleTurn(req.params.id, {}); llm,
preset,
state: game,
items: game.pending,
});
db.save(state);
broadcast(id, 'turn', { state, result });
res.json({ state, result }); res.json({ state, result });
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `jump failed: ${msg}` }); res.status(502).json({ error: `jump failed: ${msg}` });
} }
}); });
+157 -22
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm'; import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm';
function json(body: unknown) { function json(body: unknown) {
@@ -10,42 +10,88 @@ function json(body: unknown) {
} as unknown as Response; } as unknown as Response;
} }
function httpError(status: number, text = 'upstream down') {
return {
ok: false,
status,
text: async () => text,
json: async () => ({}),
} as unknown as Response;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('createLlmClient (injectable transport)', () => { describe('createLlmClient (injectable transport)', () => {
it('chat posts model+messages to {base}/chat/completions and returns content', async () => { it('chat posts model+messages to {base}/chat/completions and returns content', async () => {
const calls: { url: string; init?: RequestInit }[] = []; const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => { const transport = async (url: string, init?: RequestInit) => {
calls.push({ url, init }); sent.push({ url, init });
return json({ choices: [{ message: { content: 'hello back' } }] }); return json({ choices: [{ message: { content: 'hello back' } }] });
}; };
const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' }); const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' });
const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 }); const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 });
expect(out).toBe('hello back'); expect(out).toBe('hello back');
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized expect(sent[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
const body = JSON.parse(calls[0].init!.body as string); const body = JSON.parse(sent[0].init!.body as string);
// The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`. // The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`.
expect(body).toEqual({ expect(body).toEqual({
model: 'm1', model: 'm1',
messages: [{ role: 'user', content: 'hi' }], messages: [{ role: 'user', content: 'hi' }],
temperature: 0.5, temperature: 0.5,
max_tokens: 10, max_tokens: 10,
reasoning_effort: 'low',
}); });
// No auth header: the live vLLM endpoint requires no API key. // No auth header: the live vLLM endpoint requires no API key.
expect(calls[0].init!.headers).toEqual({ 'content-type': 'application/json' }); expect(sent[0].init!.headers).toEqual({ 'content-type': 'application/json' });
}); });
it('chat uses a generous default max_tokens when the caller omits it', async () => { it('chat uses a generous default max_tokens when the caller omits it', async () => {
const calls: { url: string; init?: RequestInit }[] = []; const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => { const transport = async (url: string, init?: RequestInit) => {
calls.push({ url, init }); sent.push({ url, init });
return json({ choices: [{ message: { content: 'ok' } }] }); return json({ choices: [{ message: { content: 'ok' } }] });
}; };
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' }); const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
await llm.chat([{ role: 'user', content: 'hi' }]); await llm.chat([{ role: 'user', content: 'hi' }]);
const body = JSON.parse(calls[0].init!.body as string); const body = JSON.parse(sent[0].init!.body as string);
expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS); expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS);
expect(body.max_tokens).toBe(4000); expect(body.max_tokens).toBe(4000);
}); });
it('chat sends reasoning_effort "low" by default', async () => {
const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
return json({ choices: [{ message: { content: 'ok' } }] });
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
await llm.chat([{ role: 'user', content: 'hi' }]);
const body = JSON.parse(sent[0].init!.body as string);
expect(body.reasoning_effort).toBe('low');
});
it('chat omits reasoning_effort when it is "off" or empty', async () => {
const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
return json({ choices: [{ message: { content: 'ok' } }] });
};
for (const effort of ['off', 'OFF', '']) {
sent.length = 0;
const llm = createLlmClient({
transport,
apiBase: 'http://x/v1',
model: 'm1',
reasoningEffort: effort,
});
await llm.chat([{ role: 'user', content: 'hi' }]);
const body = JSON.parse(sent[0].init!.body as string);
expect(body.reasoning_effort, `effort=${JSON.stringify(effort)}`).toBeUndefined();
}
});
it('listModels returns data[].id from GET {base}/models', async () => { it('listModels returns data[].id from GET {base}/models', async () => {
const transport = async (url: string) => { const transport = async (url: string) => {
expect(url).toBe('http://x/v1/models'); expect(url).toBe('http://x/v1/models');
@@ -55,21 +101,110 @@ describe('createLlmClient (injectable transport)', () => {
expect(await llm.listModels()).toEqual(['a', 'b']); expect(await llm.listModels()).toEqual(['a', 'b']);
}); });
it('chat throws on non-2xx with the HTTP status in the message', async () => { it('chat retries 5xx and succeeds once the transport recovers (500, 500 → 200)', async () => {
const transport = async () => const sent: { url: string; init?: RequestInit }[] = [];
({ const transport = async (url: string, init?: RequestInit) => {
ok: false, sent.push({ url, init });
status: 503, if (sent.length < 3) return httpError(500);
text: async () => 'upstream down', return json({ choices: [{ message: { content: 'recovered' } }] });
json: async () => ({}), };
} as unknown as Response); const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const out = await llm.chat([{ role: 'user', content: 'hi' }]);
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/); expect(out).toBe('recovered');
expect(sent).toHaveLength(3); // two failures, then success on the third attempt
}); });
it('chat throws when the response has no content', async () => { it('chat gives up after exactly 3 attempts when the transport always returns 5xx', async () => {
const transport = async () => json({ choices: [] }); const sent: { url: string; init?: RequestInit }[] = [];
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
return httpError(500, 'still down');
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 500/);
expect(sent).toHaveLength(3);
});
it('chat does not retry on 4xx (deterministic client error → first attempt only)', async () => {
const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
return httpError(400, 'bad request');
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 400/);
expect(sent).toHaveLength(1);
});
it('chat retries a null content (truncation) and then succeeds (null → content)', async () => {
const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
if (sent.length === 1) return json({ choices: [{ message: { content: null } }] });
return json({ choices: [{ message: { content: 'real content' } }] });
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
const out = await llm.chat([{ role: 'user', content: 'hi' }]);
expect(out).toBe('real content');
expect(sent).toHaveLength(2);
});
it('chat rejects with the HTTP status after 3 attempts when 5xx never recovers', async () => {
const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
return httpError(503);
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
expect(sent).toHaveLength(3);
});
it('chat rejects with a content error after 3 attempts when content is never present', async () => {
const sent: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
sent.push({ url, init });
return json({ choices: [] });
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/); await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/);
expect(sent).toHaveLength(3);
});
it('chat logs one line on success and one on final failure', async () => {
const logSpy = vi.spyOn(console, 'log');
const errorSpy = vi.spyOn(console, 'error');
const okTransport = async () =>
json({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }] });
const okLlm = createLlmClient({ transport: okTransport, apiBase: 'http://x/v1', model: 'm' });
await okLlm.chat([{ role: 'user', content: 'hi' }]);
expect(logSpy).toHaveBeenCalledWith(
'[llm] chat ok',
expect.objectContaining({ ms: expect.any(Number), finishReason: 'stop' }),
);
const failSent: { url: string; init?: RequestInit }[] = [];
const failTransport = async (url: string, init?: RequestInit) => {
failSent.push({ url, init });
return httpError(502, 'bad gateway');
};
const failLlm = createLlmClient({
transport: failTransport,
apiBase: 'http://x/v1',
model: 'm',
retryBaseDelayMs: 1,
});
await expect(failLlm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 502/);
expect(failSent).toHaveLength(3);
expect(errorSpy).toHaveBeenCalledWith(
'[llm] chat failed',
expect.objectContaining({
attempts: 3,
lastStatus: 502,
message: expect.stringContaining('HTTP 502'),
totalMs: expect.any(Number),
}),
);
}); });
}); });
+221 -36
View File
@@ -1,18 +1,28 @@
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm). // Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm).
// The transport is injectable so unit tests and the deterministic e2e stub can // The transport is injectable so unit tests and the deterministic e2e stub can
// replace the network call. Config is read from env, never hardcoded: // replace the network call. Config is read from env, never hardcoded:
// LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key) // LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key)
// LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model) // LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model)
// LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow) // LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow)
// LLM_MAX_TOKENS (default 4000 — max *output* tokens per request)
// LLM_REASONING_EFFORT (default "low" — sent as top-level `reasoning_effort`;
// empty or "off" omits the field entirely)
// //
// The live model is a REASONING model: it emits its chain-of-thought in the // The live model is a REASONING model: it emits its chain-of-thought in the
// response's `reasoning` field and the final answer in `content`. It therefore // response's `reasoning` field and the final answer in `content`. Sending
// needs a generous `max_tokens` budget to finish thinking before writing // top-level `reasoning_effort: "low"` cuts the CoT by ~85% (~4000 →
// `content` — a too-small budget makes it run out mid-reasoning and return a // ~400–600 tokens), so it fits comfortably inside the `max_tokens` budget and
// null `content`. We always send an explicit, generous `max_tokens` (the // `content` comes back populated. (Other knobs misbehave on this build:
// OpenAI/vLLM wire key — NOT camelCase `maxTokens`, which the server ignores) // "minimal" produced MORE CoT than "low", `chat_template_kwargs:
// and read only `choices[0].message.content`, ignoring `reasoning`. No auth // {enable_thinking:false}` intermittently emits invalid JSON, and `/no_think`
// header is added (the endpoint requires no key). // is silently ignored.) We read only `choices[0].message.content`, ignoring
// `reasoning`. No auth header is added (the endpoint requires no key).
//
// Reliability: `chat()` retries transient failures — timeout/abort, HTTP 5xx,
// network errors, and null/empty `content` (truncation safety net) — with up
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
// deterministic client error and throws immediately, with no retry. Every
// call is logged: one line on success, one on final failure.
export interface ChatMessage { export interface ChatMessage {
role: 'system' | 'user' | 'assistant'; role: 'system' | 'user' | 'assistant';
@@ -36,14 +46,29 @@ export const DEFAULT_BASE = 'https://ai.kkhost.pl/v1';
export const DEFAULT_MODEL = 'qwen3.8-27b'; export const DEFAULT_MODEL = 'qwen3.8-27b';
/** /**
* Generous token budget for every chat request. The live qwen3.8-27b is a * Default max *output* tokens per chat request. The live qwen3.8-27b is a
* reasoning model: it spends `reasoning_tokens` on its chain-of-thought before * reasoning model: it spends `reasoning_tokens` on its chain-of-thought before
* writing the final answer into `content`. A small budget makes it run out * writing the final answer into `content`. With `reasoning_effort: "low"` a
* mid-reasoning and return a null `content`, so we give it room to finish both. * realistic turn uses ~800–1100 output tokens, so 4000 is ~4× headroom while
* `max_tokens` is always set in the request body, even when a caller omits it. * staying under the upstream ~90s proxy wall. `max_tokens` is always set in the
* request body (the vLLM/OpenAI wire key — NOT camelCase `maxTokens`, which the
* server ignores), even when a caller omits it.
*/ */
export const DEFAULT_MAX_TOKENS = 4000; export const DEFAULT_MAX_TOKENS = 4000;
/**
* Default for `LLM_REASONING_EFFORT`. Sending top-level `reasoning_effort:
* "low"` to the vLLM model cuts its chain-of-thought by ~85%, which is what
* keeps `content` from being truncated to null.
*/
export const DEFAULT_REASONING_EFFORT = 'low';
/** Total attempts `chat()` makes before giving up (1 initial + 2 retries). */
export const MAX_ATTEMPTS = 3;
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
const RETRY_BASE_DELAY_MS = 2000;
const fetchTransport: Transport = (url, init) => fetch(url, init); const fetchTransport: Transport = (url, init) => fetch(url, init);
export interface LlmClientDeps { export interface LlmClientDeps {
@@ -51,6 +76,91 @@ export interface LlmClientDeps {
apiBase?: string; apiBase?: string;
model?: string; model?: string;
timeoutMs?: number; timeoutMs?: number;
maxTokens?: number;
reasoningEffort?: string;
/**
* Base delay (ms) for the retry backoff, overriding RETRY_BASE_DELAY_MS.
* Mostly useful in tests to keep the suite fast.
*/
retryBaseDelayMs?: number;
}
/** parseInt with a fallback for unset/empty/malformed values. */
function envInt(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/** Trimmed env value, or undefined when unset/empty. */
function envStr(name: string): string | undefined {
const raw = process.env[name]?.trim();
return raw ? raw : undefined;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Backoff before retry `n` (1-based): `2000 * 1.5^(n-1)`, ±20% jitter. */
function backoffDelay(retry: number, baseMs: number): number {
const base = baseMs * 1.5 ** (retry - 1);
return base * (0.8 + Math.random() * 0.4);
}
/** Network-level error codes that mark a transient failure. */
const RETRYABLE_NET_CODES = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ECONNABORTED',
'EPIPE',
'EHOSTUNREACH',
'ENETUNREACH',
'ETIMEDOUT',
'EAI_AGAIN',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_SOCKET',
'UND_ERR_REQ_SOCKET',
'UND_ERR_REQ_RETRY',
]);
/** True for a timeout/abort (an AbortSignal that fired, a TimeoutError, or AbortError). */
function isTimeoutOrAbort(err: unknown): boolean {
let e: unknown = err;
for (let i = 0; i < 3 && e; i++) {
if (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) return true;
e = (e as { cause?: unknown })?.cause;
}
return false;
}
/** True for a transient network failure (ECONNRESET / ECONNREFUSED / socket closed / …). */
function isNetworkError(err: unknown): boolean {
let e: unknown = err;
let msg = '';
for (let i = 0; i < 3 && e; i++) {
if (e instanceof Error) {
msg += ` ${e.message}`;
const code = (e as Error & { code?: string }).code;
if (code && RETRYABLE_NET_CODES.has(code)) return true;
} else {
msg += ` ${String(e)}`;
}
e = (e as { cause?: unknown })?.cause;
}
return /socket closed|socket hang up|connection reset|connection refused|other side closed|fetch failed/i.test(
msg,
);
}
/** One transport attempt, classified as success / retryable / fatal. */
interface Attempt {
status?: number;
error?: Error;
retryable: boolean;
content?: string;
finishReason?: string | null;
} }
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient { export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
@@ -61,36 +171,111 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'), deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'),
10, 10,
); );
const maxTokens = deps.maxTokens ?? envInt('LLM_MAX_TOKENS', DEFAULT_MAX_TOKENS);
const effort = deps.reasoningEffort ?? envStr('LLM_REASONING_EFFORT') ?? DEFAULT_REASONING_EFFORT;
// "off" (case-insensitive) or empty disables the thinking knob entirely.
const sendEffort = effort !== '' && effort.toLowerCase() !== 'off';
const retryBaseMs = deps.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
/**
* One POST to /chat/completions. Classifies the outcome:
* - success → content + finish_reason
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
*/
async function attemptOnce(payload: string, signal: AbortSignal): Promise<Attempt> {
let res: Response;
try {
res = await transport(`${apiBase}/chat/completions`, {
method: 'POST',
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
headers: { 'content-type': 'application/json' },
body: payload,
signal,
});
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
// The transport threw: our AbortSignal fired (timeout/abort) or a
// network-level failure (ECONNRESET / ECONNREFUSED / socket closed).
return { error: e, retryable: signal.aborted || isTimeoutOrAbort(e) || isNetworkError(e) };
}
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
const payload: Record<string, unknown> = { model, messages };
if (opts.temperature != null) payload.temperature = opts.temperature;
// Always send an explicit, generous `max_tokens` (the vLLM/OpenAI wire key —
// camelCase `maxTokens` is ignored by the server) so the reasoning model has
// room to finish `reasoning` before emitting `content`.
payload.max_tokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
const res = await transport(`${apiBase}/chat/completions`, {
method: 'POST',
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`); return {
status: res.status,
error: new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`),
// 4xx is a deterministic client error → do not retry; 5xx is retryable.
retryable: res.status >= 500 && res.status < 600,
};
} }
// Reasoning models also return their chain-of-thought in a separate
// `reasoning` field. We deliberately ignore it and use only the final
// answer, which lives in `content`.
const body = (await res.json()) as { const body = (await res.json()) as {
choices?: { message?: { content?: string; reasoning?: string | null } }[]; choices?: {
finish_reason?: string | null;
message?: { content?: string; reasoning?: string | null };
}[];
}; };
// The reasoning model can exhaust its output budget on the CoT and return
// a null `content` — treat that as a retryable truncation, not a success.
const content = body.choices?.[0]?.message?.content; const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') { if (typeof content !== 'string' || content === '') {
throw new Error('chat failed: no choices[0].message.content in response'); return {
status: res.status,
error: new Error('chat failed: no choices[0].message.content in response'),
retryable: true,
};
} }
return content; return {
status: res.status,
retryable: false,
content,
finishReason: body.choices?.[0]?.finish_reason ?? null,
};
}
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
const payload: Record<string, unknown> = {
model,
messages,
// The wire key is `max_tokens` (snake_case) — camelCase is ignored by the
// server. With `reasoning_effort: "low"` the CoT fits inside this budget,
// so `content` comes back populated.
max_tokens: opts.maxTokens ?? maxTokens,
};
if (opts.temperature != null) payload.temperature = opts.temperature;
if (sendEffort) payload.reasoning_effort = effort;
const totalStart = Date.now();
let lastStatus: number | undefined;
let lastError: Error = new Error('chat failed: no attempt completed');
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const out = await attemptOnce(JSON.stringify(payload), AbortSignal.timeout(timeoutMs));
if (out.status !== undefined) lastStatus = out.status;
if (out.error) lastError = out.error;
if (out.content !== undefined) {
console.log('[llm] chat ok', {
ms: Date.now() - totalStart,
finishReason: out.finishReason ?? null,
});
return out.content;
}
// Give up on a non-retryable failure, or when the last attempt is spent.
if (!out.retryable || attempt === MAX_ATTEMPTS) {
console.error('[llm] chat failed', {
attempts: attempt,
lastStatus,
message: lastError.message,
totalMs: Date.now() - totalStart,
});
throw lastError;
}
await sleep(backoffDelay(attempt, retryBaseMs));
}
// Unreachable: every loop path returns or throws.
throw lastError;
} }
async function listModels(): Promise<string[]> { async function listModels(): Promise<string[]> {
+100 -9
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { nationColor } from '../shared/protocol'; import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes.
const state: GameState = { const state: GameState = {
id: 'g', id: 'g',
presetId: 'p', presetId: 'p',
@@ -16,7 +18,7 @@ const state: GameState = {
name: 'UK', name: 'UK',
type: 'major', type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 }, stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR', 'IRL'], territory: ['GB-01', 'GB-02'],
relations: {}, relations: {},
}, },
de: { de: {
@@ -24,21 +26,110 @@ const state: GameState = {
name: 'DE', name: 'DE',
type: 'major', type: 'major',
stats: { military: 90, economy: 70, stability: 70, population: 90 }, stats: { military: 90, economy: 70, stability: 70, population: 90 },
territory: ['DEU'], territory: ['DE-01'],
relations: {}, relations: {},
}, },
}, },
events: [], events: [],
pending: [],
version: 1,
}; };
// The RegionMap built from the fixture: the authoritative sub-region id → owner
// source.
const regions: RegionMap = buildRegionMap(state);
describe('buildRegionMap', () => { describe('buildRegionMap', () => {
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => { it('maps each sub-region id to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state); const m = buildRegionMap(state);
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); expect(m['GB-01']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); expect(m['GB-02']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' }); expect(m['DE-01']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a region nobody owns is simply absent // a sub-region nobody owns is simply absent
expect(m.FRA).toBeUndefined(); expect(m['FR-57']).toBeUndefined();
});
});
describe('regionOwner', () => {
it('resolves an owned sub-region id to its owner record', () => {
expect(regionOwner(regions, 'GB-01')).toEqual({
color: nationColor('uk'),
owner: 'UK',
ownerId: 'uk',
});
expect(regionOwner(regions, 'DE-01')).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
});
});
it('returns undefined for an unowned region (no RegionMap entry)', () => {
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
expect(regionOwner(regions, 'US-01')).toBeUndefined();
});
it('returns undefined for an empty or missing id', () => {
expect(regionOwner(regions, '')).toBeUndefined();
expect(regionOwner(regions, null)).toBeUndefined();
expect(regionOwner(regions, undefined)).toBeUndefined();
});
});
/**
* #6 regression guard (sub-national map, v0.19 region-level ownership). 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`. Since v0.19 ownership is at **sub-region**
* granularity: a feature's owner lives at its **own sub-region id** (e.g.
* `DE-01`), which the client stamps into `properties.regionId` (the RegionMap
* is keyed by that id). This pins the exact data shape and proves the
* resolution: `mapFeatureOwner` works via `properties.regionId`, while the
* repacked numeric `feature.id` path does not.
*/
describe('#6 — region owner must be resolved from properties.regionId, never the 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 id, the RegionMap key) and `parent` (the
// static parent country's ISO_A3).
const repackedFeature = {
id: 0,
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
};
it('resolves the owner from properties.regionId (the fix)', () => {
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
});
});
it('the repacked numeric feature.id does NOT resolve — the original 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();
});
it('a sub-region id nobody owns does NOT resolve', () => {
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
});
});
describe('mapFeatureOwner', () => {
it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => {
expect(mapFeatureOwner(regions, null)).toBeUndefined();
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
expect(mapFeatureOwner(regions, {})).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 42 } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).toBeUndefined();
});
it('returns undefined for a sub-region nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined();
}); });
}); });
+9 -5
View File
@@ -1,6 +1,10 @@
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its // Build the choropleth payload from a GameState: every **sub-region id** in a
// owning nation plus that nation's stable color (see shared.nationColor). The // nation's territory maps to its owning nation plus that nation's stable color
// client merges this onto public/countries.geo.json and calls source.setData(). // (see shared.nationColor). The client merges this onto public/regions.geo.json
// and calls source.setData(). The map is keyed by whatever ids sit in
// `nation.territory` — sub-region ids (e.g. `FR-57`) as of v0.19 — so it needs
// no change to move from country to sub-region granularity; a sub-region in no
// nation's territory is simply absent (unowned, rendered grey by the map).
import { nationColor, type RegionMap } from '../shared/protocol'; import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
@@ -8,8 +12,8 @@ export function buildRegionMap(state: GameState): RegionMap {
const map: RegionMap = {}; const map: RegionMap = {};
for (const [id, nation] of Object.entries(state.nations)) { for (const [id, nation] of Object.entries(state.nations)) {
const color = nationColor(id); const color = nationColor(id);
for (const code of nation.territory) { for (const regionId of nation.territory) {
map[code] = { color, owner: nation.name, ownerId: id }; map[regionId] = { color, owner: nation.name, ownerId: id };
} }
} }
return map; return map;
+176
View File
@@ -0,0 +1,176 @@
/**
* server/regions.test.ts — the sub-region data module (v0.19).
*
* The first suite loads the REAL `public/regions.geo.json` (the same file the
* production server loads at boot), so expansion/migration runs against the
* actual 1,796-region dataset. The second suite exercises the graceful
* "geo missing" path (module not loaded) via the `_resetRegionsForTests` seam,
* and pins the `_setRegionsForTests` fixture seam used by the other unit tests.
* Module state is per vitest file, so these suites never collide with other
* test files.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameState } from '../shared/types';
import {
_resetRegionsForTests,
_setRegionsForTests,
expandTerritory,
hasRegionData,
loadRegions,
migrateTerritoryToRegions,
regionLabel,
regionNamesFor,
} from './regions';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function makeState(territory: string[]): GameState {
return {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory,
relations: {},
},
},
events: [],
pending: [],
version: 1,
};
}
describe('server/regions (real geo)', () => {
beforeAll(() => {
_resetRegionsForTests();
loadRegions(root);
});
it('loads public/regions.geo.json (hasRegionData becomes true)', () => {
expect(hasRegionData()).toBe(true);
});
it('loadRegions is idempotent: a second call keeps the loaded data', () => {
loadRegions(root);
expect(hasRegionData()).toBe(true);
});
it('expandTerritory expands an ISO_A3 into its sub-region ids (unique, all with that parent)', () => {
const ids = expandTerritory(['FRA']);
expect(ids.length).toBeGreaterThan(1); // France is subdivided, not one region
expect(new Set(ids).size).toBe(ids.length); // deduped
// every expanded id resolves back to the same parent country
const names = regionNamesFor(ids);
for (const id of ids) expect(names[id]).toMatch(/\(FRA\)$/);
});
it('expandTerritory is order-preserving and dedupes repeated country codes', () => {
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA']));
// two countries: the union, in first-seen order (the ids are disjoint)
expect(expandTerritory(['FRA', 'DEU'])).toEqual([
...expandTerritory(['FRA']),
...expandTerritory(['DEU']),
]);
});
it('expandTerritory of an empty or unknown list yields no regions', () => {
expect(expandTerritory([])).toEqual([]);
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
});
it('regionNamesFor maps every known id to "name (parent)"', () => {
const ids = expandTerritory(['DEU']);
const names = regionNamesFor(ids);
expect(Object.keys(names).sort()).toEqual([...ids].sort());
for (const id of ids) expect(names[id]).toMatch(/\(DEU\)$/);
});
it('regionNamesFor skips ids that are not in the loaded index', () => {
expect(regionNamesFor(['NO-SUCH-01'])).toEqual({});
});
it('regionLabel is "id — name (parent)" for known ids and the bare id for unknown', () => {
const [id] = expandTerritory(['FRA']);
expect(regionLabel(id)).toMatch(new RegExp(`^${id} — .+ \\(FRA\\)$`));
expect(regionLabel('NO-SUCH-01')).toBe('NO-SUCH-01');
});
it('migrateTerritoryToRegions rewrites ISO_A3 entries to region ids, keeps existing region ids, and is idempotent', () => {
const [deId] = expandTerritory(['DEU']); // a real region id of another country
const state = makeState(['FRA', deId]); // mixed: legacy ISO_A3 + already migrated
const originalArray = state.nations.uk.territory;
// it mutates and returns the (fresh) state it is given — callers pass a
// just-parsed object (db.load), so the in-place mutation is safe
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
// the original array object is never mutated in place — a new one is
// installed on the nation, and the original stays as it was
expect(out.nations.uk.territory).not.toBe(originalArray);
expect(originalArray).toEqual(['FRA', deId]);
// the ISO_A3 entry is gone, replaced by its sub-region ids…
expect(out.nations.uk.territory).not.toContain('FRA');
for (const id of expandTerritory(['FRA'])) expect(out.nations.uk.territory).toContain(id);
// …and the existing region id survives, exactly once
expect(out.nations.uk.territory.filter((t) => t === deId)).toEqual([deId]);
// idempotent: a second migration changes nothing
expect(migrateTerritoryToRegions(out).nations.uk.territory).toEqual(out.nations.uk.territory);
});
});
describe('server/regions (fallback: geo not loaded)', () => {
beforeAll(() => {
_resetRegionsForTests();
});
afterAll(() => {
// restore the real geo for anything in this file that runs later
_resetRegionsForTests();
loadRegions(root);
});
it('hasRegionData is false after a reset', () => {
expect(hasRegionData()).toBe(false);
});
it('expandTerritory returns its input unchanged (whole-country fallback)', () => {
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
expect(expandTerritory([])).toEqual([]);
});
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
expect(regionNamesFor(['FRA'])).toEqual({});
expect(regionLabel('FRA')).toBe('FRA');
});
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
const state = makeState(['FRA']);
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
expect(out.nations.uk.territory).toEqual(['FRA']);
});
it('_setRegionsForTests loads a fixture and drives the pure helpers', () => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
},
{ GBR: ['GB-01', 'GB-02'] },
);
expect(hasRegionData()).toBe(true);
expect(expandTerritory(['GBR'])).toEqual(['GB-01', 'GB-02']);
expect(regionNamesFor(['GB-01'])).toEqual({ 'GB-01': 'Greater London (GBR)' });
const state = makeState(['GBR']);
expect(migrateTerritoryToRegions(state).nations.uk.territory).toEqual(['GB-01', 'GB-02']);
});
});
+187
View File
@@ -0,0 +1,187 @@
// Sub-national region data: loaded ONCE at server boot from
// `public/regions.geo.json` (dev) or `dist/regions.geo.json` (prod — Vite copies
// `public/` → `dist/`). It builds two in-memory maps that power the
// sub-region-level territory model (v0.19):
//
// - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country)
// - `regionsByCountry`: ISO_A3 country → array of its region ids
//
// These are used to (a) expand a nation's ISO_A3 territory into its sub-region
// ids at game creation / load-time migration, and (b) hand the LLM a human
// name for each region id it may reference in a TurnResult.
//
// This is deliberately turf-free (plain `JSON.parse`): importing `@turf/*`
// would pull `@types/geojson` into the tsc program and break `WorldMap.tsx`
// (see AGENTS.md / the v0.17 changelog note on the geojson type gotcha).
//
// The server must still boot and the app still work when the geo file is
// missing or unreadable: the maps stay empty, `hasRegionData()` stays false,
// and the territory model gracefully degrades to whole-country ISO_A3 codes
// (the pre-v0.19 behavior). Nothing here ever throws.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { GameState } from '../shared/types';
/** A region's stable identity: display name + the parent country's ISO_A3. */
export interface RegionInfo {
name: string;
parent: string;
}
let regionIndex: Record<string, RegionInfo> = {};
let regionsByCountry: Record<string, string[]> = {};
let loaded = false;
/** True once the region geo has been loaded successfully. */
export function hasRegionData(): boolean {
return loaded;
}
/**
* (Re)build the in-memory region maps from the region geo. Idempotent: a second
* call is a no-op. Tries `<root>/public/regions.geo.json` first (dev), then
* `<root>/dist/regions.geo.json` (prod, where Vite copies `public/` → `dist/`).
* If no candidate file is found, or it fails to parse, the maps stay empty and
* `hasRegionData()` stays false (graceful fallback to ISO_A3) — never throws.
*/
export function loadRegions(root: string): void {
if (loaded) return;
const candidates = [
path.join(root, 'public', 'regions.geo.json'),
path.join(root, 'dist', 'regions.geo.json'),
];
const file = candidates.find((c) => existsSync(c));
if (!file) {
console.warn(
'[history] regions.geo.json not found (tried: ' + candidates.join(', ') +
') — falling back to whole-country (ISO_A3) territory',
);
return;
}
try {
const geo = JSON.parse(readFileSync(file, 'utf8')) as {
features?: Array<{
id?: string | number;
properties?: { name?: string; parent?: string };
}>;
};
const index: Record<string, RegionInfo> = {};
const byCountry: Record<string, string[]> = {};
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
if (!id || !parent) continue;
index[id] = { name, parent };
(byCountry[parent] ??= []).push(id);
}
regionIndex = index;
regionsByCountry = byCountry;
loaded = Object.keys(index).length > 0;
} catch (e) {
console.warn('[history] failed to load regions.geo.json (' + file + '):', e);
}
}
/**
* Expand a list of ISO_A3 country codes into the union of all their sub-region
* ids (deduped, preserving first-seen order). When the region data is not
* loaded, returns the input unchanged (graceful fallback to ISO_A3 territory).
*/
export function expandTerritory(countryCodes: string[]): string[] {
if (!loaded) return countryCodes;
const seen = new Set<string>();
const out: string[] = [];
for (const code of countryCodes) {
for (const id of regionsByCountry[code] ?? []) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
}
return out;
}
/**
* A `{ [regionId]: "name (parent)" }` object for the given region ids, for the
* LLM prompt. Unknown ids (not in the loaded index) are skipped. Empty when the
* region data is not loaded.
*/
export function regionNamesFor(ids: Iterable<string>): Record<string, string> {
const out: Record<string, string> = {};
for (const id of ids) {
const info = regionIndex[id];
if (info) out[id] = `${info.name} (${info.parent})`;
}
return out;
}
/**
* Human-readable label for a single region id: `id — name (parent)`, or just
* the id when it is unknown (e.g. a region that predates the geo or a
* synthetic id).
*/
export function regionLabel(id: string): string {
const info = regionIndex[id];
return info ? `${id} — ${info.name} (${info.parent})` : id;
}
/** A bare ISO_A3 country code: exactly three uppercase letters (no hyphen). */
const ISO_A3_RE = /^[A-Z]{3}$/;
/**
* Migrate a loaded game's territory from whole-country ISO_A3 codes to
* sub-region ids, for nations that still carry ISO_A3 entries (i.e. games
* saved before v0.19). Each such nation's ISO_A3 codes are expanded to all
* their sub-region ids; any entries that are already region ids (they contain
* a hyphen, so they never match `ISO_A3_RE`) are kept. Idempotent and
* no-op when the region data is not loaded or a nation has no ISO_A3 codes.
* Mutates and returns the (fresh) state it is given.
*/
export function migrateTerritoryToRegions(state: GameState): GameState {
if (!loaded) return state;
for (const n of Object.values(state.nations)) {
if (!n.territory.some((t) => ISO_A3_RE.test(t))) continue;
const isoCodes = n.territory.filter((t) => ISO_A3_RE.test(t));
const existingRegions = n.territory.filter((t) => !ISO_A3_RE.test(t));
const merged = expandTerritory(isoCodes).concat(existingRegions);
const seen = new Set<string>();
const out: string[] = [];
for (const id of merged) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
n.territory = out;
}
return state;
}
// --- Test-only seams ---------------------------------------------------------
/**
* Test-only: reset the module state to "not loaded" (empty maps,
* `hasRegionData()` false). Lets a test exercise the graceful-fallback path.
*/
export function _resetRegionsForTests(): void {
regionIndex = {};
regionsByCountry = {};
loaded = false;
}
/**
* Test-only: inject region maps directly (bypassing the file load) so a unit
* test can drive `expandTerritory` / `regionNamesFor` / `regionLabel` /
* `migrateTerritoryToRegions` against a small, deterministic fixture without
* reading the 3.4 MB geo file. `loaded` becomes true iff `index` is non-empty.
*/
export function _setRegionsForTests(
index: Record<string, RegionInfo>,
byCountry: Record<string, string[]>,
): void {
regionIndex = index;
regionsByCountry = byCountry;
loaded = Object.keys(index).length > 0;
}
+46 -32
View File
@@ -2,20 +2,22 @@
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It // sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport. // speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
import { createLlmClient, type ChatMessage, type Transport } from './llm'; import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TurnResult } from '../shared/protocol'; import { addDays, clampStat, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RoundItem } from '../shared/types';
export const ADVISOR_MARKER = '<<ADVISOR>>'; export const ADVISOR_MARKER = '<<ADVISOR>>';
/** The parsed body of the user message the engine sends (see engine.buildPrompt). */ /** The parsed body of the user message the engine sends (see engine.buildUserPrompt). */
export interface StubPayload { export interface StubPayload {
state?: { state?: {
turn: number; turn: number;
date: string; date: string;
playerNationId: string; playerNationId: string;
nations: Record<string, { stats: Record<string, number> }>; /** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
}; };
action?: string | null; /** The raw pending round the engine emitted (one entry per queued item). */
chat?: { to: string; message: string } | null; pending?: RoundItem[];
} }
function truncate(s: string, n: number): string { function truncate(s: string, n: number): string {
@@ -24,8 +26,17 @@ function truncate(s: string, n: number): string {
/** /**
* Pure deterministic TurnResult for a parsed payload. Bumps the player nation's * Pure deterministic TurnResult for a parsed payload. Bumps the player nation's
* military by 5 each turn (clamped to 0–100) and emits one event, so a caller can * military by 5 each turn (clamped to 0–100) and emits one event per queued
* predict the exact next state for assertions. * round item (action → military/success, diplomacy → diplomatic/partial), or a
* single political "The world moves on" event when the round is empty — so a
* caller can predict the exact next state for assertions. It always sets the
* player's military to (current + 5) and allies the player with the first other
* nation (by key order), advancing the date by 2 days. As of v0.19, when the
* round is non-empty it also makes exactly **one** territory move: the player
* annexes the lexicographically-first sub-region id of that first other
* nation's territory (an empty round makes no move, and a player with nothing
* to annex from that nation also makes no move) — so unit tests and e2e can
* assert the exact region that changes hands.
*/ */
export function deterministicTurn(p: StubPayload): TurnResult { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; const state = p.state;
@@ -35,43 +46,46 @@ export function deterministicTurn(p: StubPayload): TurnResult {
const otherId = state const otherId = state
? (Object.keys(state.nations).find((id) => id !== pid) ?? '') ? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
: 'us'; : 'us';
const items = p.pending ?? [];
// The one deterministic per-turn territory move (see the doc above).
const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort();
const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : [];
const events = p.action const events: TurnEventSpec[] =
? [ items.length === 0
{
nation: pid,
type: 'military' as const,
title: `Action: ${truncate(p.action, 60)}`,
text: 'Your move takes effect across the region.',
outcome: 'success' as const,
},
]
: p.chat
? [ ? [
{ {
nation: pid, nation: pid,
type: 'diplomatic' as const, type: 'political',
title: 'Diplomatic outreach', title: 'The world moves on',
text: 'Your message is received by the target nation.', text: 'Weeks pass without a decisive event.',
outcome: 'partial' as const, outcome: 'neutral',
}, },
] ]
: [ : items.map((it) =>
{ it.kind === 'action'
nation: pid, ? {
type: 'political' as const, nation: pid,
title: 'The world moves on', type: 'military' as const,
text: 'Weeks pass; the great powers posture and rearm quietly.', title: `Action: ${truncate(it.text, 60)}`,
outcome: 'neutral' as const, text: 'Your move takes effect across the region.',
}, outcome: 'success' as const,
]; }
: {
nation: pid,
type: 'diplomatic' as const,
title: 'Diplomatic outreach',
text: 'Your message is received.',
outcome: 'partial' as const,
},
);
return { return {
date: addDays(state?.date ?? '1939-09-01', 2), date: addDays(state?.date ?? '1939-09-01', 2),
events, events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }], stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [], relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges: [], territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [],
}; };
} }
+85 -7
View File
@@ -23,7 +23,13 @@ export interface RelationChange {
set: RelationStatus; set: RelationStatus;
} }
/** A nation gains/loses ISO_A3 regions this turn. */ /**
* A nation gains/loses **sub-region ids** this turn (e.g. `FR-57`), not
* whole-country ISO_A3 codes. Each id identifies a single sub-region, so a
* border can shift one region at a time; a gained region is moved out of
* whichever nation currently owns it, and a lost region with no matching
* gainer simply becomes unowned (no-man's-land).
*/
export interface TerritoryChange { export interface TerritoryChange {
nation: string; nation: string;
gain: string[]; gain: string[];
@@ -48,14 +54,57 @@ export interface TurnResult {
territoryChanges: TerritoryChange[]; territoryChanges: TerritoryChange[];
} }
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */ /** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
export interface RegionOwner { export interface RegionOwner {
color: string; color: string;
owner: string; owner: string;
ownerId: string; ownerId: string;
} }
/**
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
* Every sub-region any nation owns appears here; a sub-region in no nation's
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
*/
export type RegionMap = Record<string, 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 **sub-region's own id** (e.g. `FR-57` — never a
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
* key or an unowned region (one in no nation's territory).
*
* 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.regionId` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
* #6).
*/
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. Ownership is now at **sub-region** granularity, so a
* feature's owner is found via its **own sub-region id** — the client stamps
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
* we look that up with `regionOwner`. This is the single shared source of truth
* for that resolution, so the "use `properties.regionId`, never the repacked
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" rule
* lives in one tested place rather than in the React component.
*
* Returns `undefined` for a missing feature/regionId or an unowned region.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { regionId?: unknown } } | null | undefined,
): RegionOwner | undefined {
const regionId = feature?.properties?.regionId;
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */ /** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
export type SseMessage = export type SseMessage =
| { type: 'state'; state: GameState } | { type: 'state'; state: GameState }
@@ -72,17 +121,41 @@ export interface CreateGameResponse {
game: GameState; game: GameState;
} }
export interface TurnRequest { /** Queue a free-text player action onto the pending round (no turn advance). */
/** Free-text player action. Omit for a "jump forward" auto turn. */ export interface AddActionRequest {
action?: string; text: string;
/** Optional diplomatic message to a target nation. */
chat?: { to: string; message: string };
} }
export interface AddActionResponse {
game: GameState;
}
/** Queue a diplomatic message onto the pending round (no turn advance). */
export interface AddDiplomacyRequest {
to: string;
message: string;
}
export interface AddDiplomacyResponse {
game: GameState;
}
/**
* "Jump forward" — commit the whole pending round to the model in a single
* turn. The body is empty; everything being committed lives in `game.pending`.
*/
export type JumpRequest = Record<string, never>;
export interface TurnResponse { export interface TurnResponse {
state: GameState; state: GameState;
result: TurnResult; result: TurnResult;
} }
/** Remove one queued item from the pending round, by its index in `game.pending`. */
export interface RemoveQueueItemRequest {
index: number;
}
export interface RemoveQueueItemResponse {
game: GameState;
}
export interface AdvisorRequest { export interface AdvisorRequest {
/** Optional prompt to steer the brainstorm. */ /** Optional prompt to steer the brainstorm. */
prompt?: string; prompt?: string;
@@ -100,6 +173,11 @@ export interface GameSummary {
date: string; date: string;
} }
/** Response for deleting a saved game (see `DELETE /api/games/:id`). */
export interface DeleteGameResponse {
deleted: boolean;
}
/** /**
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id * Deterministic, stable per-nation color for the choropleth. Hashes the nation id
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so * to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
+43 -2
View File
@@ -26,8 +26,15 @@ export const RELATION_STATUSES: RelationStatus[] = [
]; ];
/** /**
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors * A single nation in the world. `territory` holds **sub-region ids** (e.g.
* each of those regions with this nation's stable color (see protocol.nationColor). * `FR-57`) — one entry per individually-ownable sub-region, at sub-region
* granularity rather than whole-country ISO_A3. The map colors each sub-region
* with this nation's stable color (see protocol.nationColor). At scenario start
* a nation owns **all** sub-regions of the countries it holds (the server
* expands each ISO_A3 to its sub-regions; see server/regions.expandTerritory),
* so no region starts unowned. A sub-region that is in **no** nation's list is
* unowned (no-man's-land) — the map renders it grey and it can be gained or
* lost region-by-region by the LLM.
*/ */
export interface Nation { export interface Nation {
id: string; id: string;
@@ -74,6 +81,28 @@ export interface EventEntry {
outcome: EventOutcome; outcome: EventOutcome;
} }
/**
* A single item queued in the current round, waiting to be committed by a
* "Jump forward". `kind: 'action'` is a free-text player move; `kind:
* 'diplomacy'` is a message to a specific nation (`to`).
*/
export type RoundItem = {
kind: 'action' | 'diplomacy';
text: string;
to?: string;
};
/**
* Monotonic revision counter for a game, bumped by the server on every
* mutation (queue add/remove, turn commit) and persisted with the state.
* Clients use it to tell a fresh state from a stale one: an SSE `state`/`turn`
* snapshot (e.g. the on-connect resync) that arrives *after* a fresher REST
* response carries a lower version and must be ignored, never applied
* (see the client's "never go backwards" rule in src/App.tsx). Legacy games
* saved before this field existed are treated as version 0 by consumers.
*/
export type GameVersion = number;
/** The full, persisted state of one game. Saved to SQLite by id. */ /** The full, persisted state of one game. Saved to SQLite by id. */
export interface GameState { export interface GameState {
id: string; id: string;
@@ -84,6 +113,18 @@ export interface GameState {
date: string; date: string;
nations: Record<string, Nation>; nations: Record<string, Nation>;
events: EventEntry[]; events: EventEntry[];
/**
* Actions + diplomatic messages queued this round but not yet committed.
* Adding an item never advances the turn; a "Jump forward" processes the whole
* batch in a single turn and clears this back to `[]`.
*/
pending: RoundItem[];
/**
* Monotonic revision counter (see GameVersion). Bumped on every mutation so
* clients can reject stale SSE snapshots; see the "never go backwards" rule
* in src/App.tsx. Starts at 1 on game creation.
*/
version: GameVersion;
} }
/** /**
+334 -118
View File
@@ -7,8 +7,6 @@ import {
ListTree, ListTree,
Loader2, Loader2,
Map as MapIcon, Map as MapIcon,
Minus,
Plus,
Radar, Radar,
} from 'lucide-react'; } from 'lucide-react';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
@@ -18,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { cn } from './lib/utils'; import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker'; import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect'; import NationSelect from './components/NationSelect';
import WorldMap, { type MapApi } from './components/WorldMap'; import WorldMap from './components/WorldMap';
import FloatingWindow from './components/FloatingWindow'; import FloatingWindow from './components/FloatingWindow';
import MapLegend from './components/MapLegend'; import MapLegend from './components/MapLegend';
import NationPanel from './components/NationPanel'; import NationPanel from './components/NationPanel';
@@ -46,13 +44,20 @@ function toastError(e: unknown): void {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Floating-window collapse persistence (positions live in FloatingWindow itself). // Floating-window collapse + dock persistence.
// (Free-floating *positions* live in FloatingWindow itself, per id.)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed'; const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock';
/** Every collapsible HUD panel (the slim header is never collapsed). */ /**
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const; * Every collapsible HUD panel (the slim, pinned header is never collapsed).
* The three substantive panels (dossier / feed / action) are also dockable;
* the legend stays a small floating widget, and there is no longer a "Map"
* window.
*/
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const;
function loadCollapsedMap(): Record<string, boolean> { function loadCollapsedMap(): Record<string, boolean> {
try { try {
@@ -78,6 +83,33 @@ function persistCollapsedMap(m: Record<string, boolean>): void {
} }
} }
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
type DockMode = 'left' | 'right' | 'float';
function loadDockMap(): Record<string, DockMode> {
try {
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
if (raw == null) return {};
const v: unknown = JSON.parse(raw);
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
const out: Record<string, DockMode> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
}
return out;
} catch {
return {};
}
}
function persistDockMap(m: Record<string, DockMode>): void {
try {
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
} catch {
/* storage unavailable — dock choices just won't persist */
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Active-game persistence. The id of the game the user is (or was last) playing // Active-game persistence. The id of the game the user is (or was last) playing
// is remembered in localStorage, so a page refresh drops them straight back // is remembered in localStorage, so a page refresh drops them straight back
@@ -111,6 +143,28 @@ function clearActiveGameId(): void {
} }
} }
// ---------------------------------------------------------------------------
// Hash routing. The active game's id lives in the URL fragment: home is `#/`
// (a bare page with no hash is also home) and an active game is `#/game/<id>`.
// This manual hash sync — no react-router, no server-side route — fits the
// existing state-based router and is static/Traefik safe, because the fragment
// never reaches the server (every non-API GET just serves `index.html`). The
// localStorage active-game id above stays as the fallback for a bare `/` load;
// the URL hash takes precedence when present.
// ---------------------------------------------------------------------------
/** The game id encoded in `#/game/<id>`, or `null` when the hash is home/bare. */
function hashGameId(hash: string): string | null {
const m = hash.match(/^#\/game\/(.+)$/);
return m && m[1].length > 0 ? m[1] : null;
}
/** Write the home route (`#/`) into the URL hash unless it is already home/bare. */
function setHomeHash(): void {
const h = window.location.hash;
if (h !== '' && h !== '#/') window.location.hash = '#/';
}
export default function App() { export default function App() {
const [screen, setScreen] = useState<Screen>({ name: 'home' }); const [screen, setScreen] = useState<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]); const [presets, setPresets] = useState<PresetMeta[]>([]);
@@ -119,10 +173,41 @@ export default function App() {
const [regions, setRegions] = useState<RegionMap>({}); const [regions, setRegions] = useState<RegionMap>({});
const [advisors, setAdvisors] = useState<string[]>([]); const [advisors, setAdvisors] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Separate from `busy`: an advisor fetch is its own LLM call. It must NOT
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
// it only gates the advisor UI (the "Ask advisor" button + its hint).
const [advisorBusy, setAdvisorBusy] = useState(false);
// The id of the game the user is/was last in (mirrors localStorage) and the // The id of the game the user is/was last in (mirrors localStorage) and the
// list of saved games (newest first) shown on the home screen. // list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId()); const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
const [savedGames, setSavedGames] = useState<GameSummary[]>([]); const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
// Once the boot fetch resolves (success or fallback), allow the URL-sync effect
// to start writing the hash. Until then, a deep link in the URL must not be
// clobbered by an app-driven "home" write during the initial render.
const bootedRef = useRef(false);
// Latest game + handlers, read by the (mount-once) hashchange listener so it
// never acts on a stale closure. Written each render (same pattern as sse.ts).
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
// The single entry point for adopting a full game state — SSE `state`/`turn`
// events AND the REST responses (add/remove/jump) AND game loads all flow
// through here. Two rules:
// • a *different* game id (new game, deep link, resume) is always adopted;
// • the *same* game is adopted only when its `version` is not older than
// the current state's ("never go backwards").
// The comparison happens inside the functional updater, so it always reads
// the latest committed state — never a stale closure. This is what makes a
// late-arriving stale SSE snapshot (e.g. the on-connect resync taken just
// before an add was saved, or an EventSource auto-reconnect) harmless: it
// carries a lower version than the fresher state the REST response already
// applied, so it is dropped instead of clobbering the queued item (#10).
// Legacy games saved before `version` existed are treated as version 0.
const applyState = useCallback((s: GameState) => {
setGame((cur) => {
if (!cur || cur.id !== s.id) return s;
return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur;
});
}, []);
// On the home screen: load the scenario manifest and the list of saved games // On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game. // (newest-updated first) so the user can resume a prior game.
@@ -142,28 +227,36 @@ export default function App() {
}; };
}, [screen.name]); }, [screen.name]);
// On first mount, auto-resume the last active game (if any is stored): fetch // On first mount, decide which game to show and load it. A deep link in the
// its persisted state, remember it, and drop the user straight back into the // URL (`#/game/<id>`) takes precedence over the localStorage auto-resume; a
// game screen. The map is refetched by the effect below (keyed on `game`) and // bare page (no hash) falls back to the stored active game. The map is
// the `useSse(game?.id)` effect re-attaches the stream, resyncing from the // refetched by the effect below (keyed on `game`) and `useSse(game?.id)`
// server on connect. If the id is stale (404 / deleted), clear it and fall // re-attaches the stream, resyncing from the server on connect. A bad or
// back to the home screen gracefully. // missing id (404) must never crash: we clear the hash to `#/`, drop any
// stale stored id, and stay on the home screen. Once boot resolves (success
// or fallback) `bootedRef` flips so the URL-sync effect may start writing.
useEffect(() => { useEffect(() => {
const id = readActiveGameId(); const id = hashGameId(window.location.hash) ?? readActiveGameId();
if (!id) return; if (!id) {
bootedRef.current = true; // nothing to load — activate URL syncing
return;
}
let alive = true; let alive = true;
(async () => { (async () => {
try { try {
const { game } = await api.getGame(id); const { game } = await api.getGame(id);
if (!alive) return; if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id); setActiveGameId(id);
setGame(game); applyState(game);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch { } catch {
if (alive) { if (!alive) return;
clearActiveGameId(); clearActiveGameId();
setActiveGameId(null); setActiveGameId(null);
} setHomeHash(); // bad/missing id → fall back to the home screen
} finally {
bootedRef.current = true; // boot resolved — activate URL syncing
} }
})(); })();
return () => { return () => {
@@ -171,6 +264,38 @@ export default function App() {
}; };
}, []); }, []);
// Keep the URL in sync with the current screen: a loaded game is
// `#/game/<id>`; everything else (home and the pick screens) is `#/`. This is
// the single writer of the hash for app-driven transitions (game created,
// resumed, or leaving for home). It only writes when the desired hash differs
// from the current one, so it never triggers a redundant `hashchange`.
useEffect(() => {
if (!bootedRef.current) return;
const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/';
if (window.location.hash !== desired) window.location.hash = desired;
}, [screen.name, game?.id]);
// React to user-driven hash changes: manual edits and browser back/forward.
// Only re-act when the parsed id actually differs from the loaded game (or the
// hash went home while we're in a game), so the writes above never loop. Uses
// `latestRef` so the mount-once effect always sees the current game + handlers.
useEffect(() => {
const onHashChange = () => {
const cur = latestRef.current;
if (!cur) return;
const id = hashGameId(window.location.hash);
if (id) {
if (id === cur.game?.id) return; // already showing this game — no-op
void cur.loadGame(id);
} else if (cur.game) {
cur.goHome(); // hash is home but we're in a game — return home
}
// else: hash is home and we're already on home/pick — nothing to do
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
// Keep the choropleth in sync with whatever state is shown (territory changes). // Keep the choropleth in sync with whatever state is shown (territory changes).
useEffect(() => { useEffect(() => {
if (!game) return; if (!game) return;
@@ -181,10 +306,15 @@ export default function App() {
}, [game]); }, [game]);
// Resync from the persisted server state on connect and after every turn. // Resync from the persisted server state on connect and after every turn.
// Handlers only call the stable setGame, so no stale-closure concerns.
useSse(game?.id ?? null, { useSse(game?.id ?? null, {
onState: (s) => setGame(s), onState: applyState,
onTurn: (s) => setGame(s), onTurn: applyState,
// The game disappeared while this tab had it open — either deleted from
// another tab (the server broadcasts an SSE `error`) or a stale deep link to
// a missing game (the SSE connect emits `error`). Return this tab home.
onError: (msg) => {
if (/deleted|not found/i.test(msg)) latestRef.current?.goHome();
},
}); });
const play = useCallback(async (presetId: string) => { const play = useCallback(async (presetId: string) => {
@@ -213,7 +343,7 @@ export default function App() {
// Remember this as the active game so a refresh resumes it. // Remember this as the active game so a refresh resumes it.
persistActiveGameId(game.id); persistActiveGameId(game.id);
setActiveGameId(game.id); setActiveGameId(game.id);
setGame(game); applyState(game);
setAdvisors([]); setAdvisors([]);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch (e) { } catch (e) {
@@ -225,78 +355,105 @@ export default function App() {
[screen], [screen],
); );
const runAction = useCallback( // Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
async (action: string) => { // list updates from the response and the SSE `state` event. Both are
// version-gated via applyState, so the item can never be clobbered by a
// stale snapshot (#10).
const runAddAction = useCallback(
async (text: string) => {
if (!game) return; if (!game) return;
setBusy(true);
try { try {
const { state } = await api.turn(game.id, action); const { game: g } = await api.addAction(game.id, text);
setGame(state); applyState(g);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} finally {
setBusy(false);
} }
}, },
[game], [game, applyState],
); );
const runChat = useCallback( // Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`.
const runAddDiplomacy = useCallback(
async (to: string, message: string) => { async (to: string, message: string) => {
if (!game) return; if (!game) return;
setBusy(true);
try { try {
const { state } = await api.turn(game.id, undefined, { to, message }); const { game: g } = await api.addDiplomacy(game.id, to, message);
setGame(state); applyState(g);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} finally {
setBusy(false);
} }
}, },
[game], [game, applyState],
); );
// Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list
// updates from the response and the SSE `state` event (version-gated).
const runRemoveQueueItem = useCallback(
async (index: number) => {
if (!game) return;
try {
const { game: g } = await api.removeQueueItem(game.id, index);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
);
// Jump forward: the single LLM call that commits the pending round. Only this
// sets `busy` (the adds above are fast and non-LLM).
const runJump = useCallback(async () => { const runJump = useCallback(async () => {
if (!game) return; if (!game) return;
setBusy(true); setBusy(true);
try { try {
const { state } = await api.jump(game.id); const { state } = await api.jump(game.id);
setGame(state); applyState(state);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [game]); }, [game, applyState]);
// Ask the advisor: a separate (slow) LLM call. It sets its own `advisorBusy`
// flag (distinct from the turn `busy`) and clears it in `finally`, so the flag
// is released on both success and error. Asking never advances a turn and
// never touches the turn `busy` state — "Jump forward" stays usable.
const askAdvisor = useCallback( const askAdvisor = useCallback(
async (prompt?: string) => { async (prompt?: string) => {
if (!game) return; if (!game) return;
setAdvisorBusy(true);
try { try {
const { suggestions } = await api.advisor(game.id, prompt); const { suggestions } = await api.advisor(game.id, prompt);
setAdvisors(suggestions); setAdvisors(suggestions);
} catch (e) { } catch (e) {
toastError(e); toastError(e);
} finally {
setAdvisorBusy(false);
} }
}, },
[game], [game],
); );
const resumeGame = useCallback(async (id: string) => { // Load (fetch + show) a saved game by id. Shared by the home screen's resume
// affordances and by the hash router when the URL points at a different game.
// On failure (deleted / 404) it never throws: it clears the stale reference,
// clears the hash to `#/`, and stays on the home screen.
const loadGame = useCallback(async (id: string) => {
setLoading(true); setLoading(true);
try { try {
const { game } = await api.getGame(id); const { game } = await api.getGame(id);
// Make this the active game again so a refresh keeps resuming it. // Make this the active game so a bare `/` reload keeps resuming it.
persistActiveGameId(id); persistActiveGameId(id);
setActiveGameId(id); setActiveGameId(id);
setGame(game); applyState(game);
setAdvisors([]); setAdvisors([]);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch (e) { } catch (e) {
// Deleted / not found: clear any stale reference and stay on home.
clearActiveGameId(); clearActiveGameId();
setActiveGameId(null); setActiveGameId(null);
setHomeHash();
setScreen({ name: 'home' }); setScreen({ name: 'home' });
toastError(e); toastError(e);
} finally { } finally {
@@ -304,6 +461,9 @@ export default function App() {
} }
}, []); }, []);
// The home screen's "resume" affordances just load a saved game by id.
const resumeGame = useCallback((id: string) => void loadGame(id), [loadGame]);
const goHome = useCallback(() => { const goHome = useCallback(() => {
setScreen({ name: 'home' }); setScreen({ name: 'home' });
setGame(null); setGame(null);
@@ -311,6 +471,32 @@ export default function App() {
setAdvisors([]); setAdvisors([]);
}, []); }, []);
// Delete a saved game. Removes it from the "Saved games" list, clears it if it
// was the remembered active game, and heads home if it's the one currently
// shown. The server also broadcasts an SSE `error` to any *other* open tab of
// this game, which the `useSse` `onError` handler above turns into a go-home.
const runDeleteGame = useCallback(
async (id: string) => {
try {
await api.deleteGame(id);
} catch (e) {
toastError(e);
return;
}
setSavedGames((prev) => prev.filter((g) => g.id !== id));
if (activeGameId === id) {
clearActiveGameId();
setActiveGameId(null);
}
if (game?.id === id) goHome();
toast.success('Game deleted');
},
[activeGameId, game, goHome],
);
// Expose the latest game + handlers to the mount-once hashchange listener.
latestRef.current = { game, goHome, loadGame };
if (screen.name === 'game' && game) { if (screen.name === 'game' && game) {
return ( return (
<> <>
@@ -320,9 +506,11 @@ export default function App() {
game={game} game={game}
regions={regions} regions={regions}
busy={busy} busy={busy}
advisorBusy={advisorBusy}
advisors={advisors} advisors={advisors}
onAction={runAction} onAddAction={runAddAction}
onChat={runChat} onAddDiplomacy={runAddDiplomacy}
onRemoveQueueItem={runRemoveQueueItem}
onAdvisor={askAdvisor} onAdvisor={askAdvisor}
onJump={runJump} onJump={runJump}
onHome={goHome} onHome={goHome}
@@ -350,6 +538,7 @@ export default function App() {
savedGames={savedGames} savedGames={savedGames}
activeGameId={activeGameId} activeGameId={activeGameId}
onResume={resumeGame} onResume={resumeGame}
onDelete={runDeleteGame}
/> />
</> </>
); );
@@ -358,10 +547,14 @@ export default function App() {
interface GameScreenProps { interface GameScreenProps {
game: GameState; game: GameState;
regions: RegionMap; regions: RegionMap;
/** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */
busy: boolean; busy: boolean;
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
advisorBusy: boolean;
advisors: string[]; advisors: string[];
onAction: (a: string) => void; onAddAction: (a: string) => void;
onChat: (to: string, m: string) => void; onAddDiplomacy: (to: string, m: string) => void;
onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void; onAdvisor: (p?: string) => void;
onJump: () => void; onJump: () => void;
onHome: () => void; onHome: () => void;
@@ -369,28 +562,46 @@ interface GameScreenProps {
/** /**
* The main play view — a full-bleed world map with a floating HUD. The MapLibre * The main play view — a full-bleed world map with a floating HUD. The MapLibre
* map is a fixed, full-viewport background (z-0); over it float draggable, * map is a fixed, full-viewport background (z-0) that the user drives directly
* collapsible windows: a slim header bar (scenario, turn + date, player nation, * (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
* live status, "Focus map" collapse-all) at the top, the nation dossier * a slim **pinned** header bar (fixed, non-movable, centered at the top a few
* (top-left), the event feed (right), the "Your move" command deck * px down; scenario, turn + date, player nation, live status, "Focus map"
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom * collapse-all); the nation dossier, the event feed, and the "Your move"
* control (bottom-right). Each window remembers its dragged position, and * command deck — each free-floating by default (at the same first-run positions
* clicking one raises it above its siblings. Pan/zoom/click on the map stays * as before) but **dockable** into a full-height left or right pane, the choice
* fully available in the gaps between windows. * being remembered; and the nation-color legend, which stays a small floating
* widget (not dockable). Each floating window remembers its dragged position,
* and clicking one raises it above its siblings. Pan/zoom/click on the map
* stays fully available in the gaps between windows.
*/ */
function GameScreen({ function GameScreen({
game, game,
regions, regions,
busy, busy,
advisorBusy,
advisors, advisors,
onAction, onAddAction,
onChat, onAddDiplomacy,
onRemoveQueueItem,
onAdvisor, onAdvisor,
onJump, onJump,
onHome, onHome,
}: GameScreenProps) { }: GameScreenProps) {
// Which nation's dossier to show. Defaults to the player; remounts per game. // Which nation's dossier to show. Defaults to the player; remounts per game.
const [viewingId, setViewingId] = useState(game.playerNationId); const [viewingId, setViewingId] = useState(game.playerNationId);
// When the dossier target was set by a map click, remember which sub-region
// was clicked, for a small label in the dossier. The label shows only the
// region's name — the static parent-country code (which never updates on a
// transfer) is no longer displayed, though the info object still carries it.
// 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 player = game.nations[game.playerNationId];
const viewed = game.nations[viewingId] ?? player; const viewed = game.nations[viewingId] ?? player;
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name)); const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
@@ -417,11 +628,23 @@ function GameScreen({
}); });
}, []); }, []);
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control. // Dock state for the three dockable panels (dossier / feed / action),
const mapApiRef = useRef<MapApi | null>(null); // persisted per id (survives reloads). The default is 'float' — the current
const handleMapReady = useCallback((api: MapApi | null) => { // first-run positions — so nothing visually changes until the user docks
mapApiRef.current = api; // something. The legend is not dockable; the header is pinned.
const [dock, setDock] = useState<Record<string, DockMode>>(loadDockMap);
const setDockFor = useCallback((id: string, mode: DockMode) => {
setDock((prev) => {
const next = { ...prev, [id]: mode };
persistDockMap(next);
return next;
});
}, []); }, []);
// The `dock` prop expects a concrete edge, or `undefined` to free-float.
const dockOf = (id: string) => {
const m = dock[id];
return m === 'left' || m === 'right' ? m : undefined;
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -431,14 +654,15 @@ function GameScreen({
playerNationId={game.playerNationId} playerNationId={game.playerNationId}
nations={game.nations} nations={game.nations}
presetId={game.presetId} presetId={game.presetId}
onFocusNation={setViewingId} onFocusNation={focusNation}
onMapReady={handleMapReady}
/> />
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */} {/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
Fixed and non-movable, centered at the top a few px from the top edge. */}
<FloatingWindow <FloatingWindow
id="header" id="header"
anchor="top-center" dataTestId="top-bar"
pinned
collapsible={false} collapsible={false}
title={ title={
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@@ -485,17 +709,24 @@ function GameScreen({
{player?.name ?? game.playerNationId} {player?.name ?? game.playerNationId}
</span> </span>
</div> </div>
{/* Three-state live status: advisor fetch in flight → "Advisor
thinking…"; else a turn in flight → "Simulating…"; else "Idle".
Both LLM-in-flight states share the amber spinner; idle is muted. */}
<span <span
data-testid="status-indicator" data-testid="status-indicator"
className={cn( className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium', 'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
busy advisorBusy || busy
? 'border-primary/40 bg-primary/10 text-primary' ? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border bg-muted/40 text-muted-foreground', : 'border-border bg-muted/40 text-muted-foreground',
)} )}
> >
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />} {advisorBusy || busy ? (
{busy ? 'Simulating…' : 'Idle'} <Loader2 className="size-3 animate-spin" />
) : (
<CircleDot className="size-3" />
)}
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
</span> </span>
<Button <Button
variant="secondary" variant="secondary"
@@ -511,13 +742,16 @@ function GameScreen({
} }
/> />
{/* Nation dossier, top-left. */} {/* Nation dossier, top-left by default; dockable into a left/right pane. */}
<FloatingWindow <FloatingWindow
id="dossier" id="dossier"
dataTestId="panel-dossier"
anchor="top-left" anchor="top-left"
width={300} width={300}
title="Dossier" title="Dossier"
titleIcon={<BookOpen className="size-4" />} titleIcon={<BookOpen className="size-4" />}
dock={dockOf('dossier')}
onDockChange={(m) => setDockFor('dossier', m)}
collapsed={!!collapsed.dossier} collapsed={!!collapsed.dossier}
onCollapsedChange={(c) => setCollapsedFor('dossier', c)} onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
bodyClassName="max-h-[56vh] overflow-y-auto" bodyClassName="max-h-[56vh] overflow-y-auto"
@@ -526,7 +760,14 @@ function GameScreen({
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Inspect Inspect
</div> </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"> <SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -538,13 +779,19 @@ function GameScreen({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{selectedRegion && (
<div className="mt-1.5 truncate text-[11px] text-muted-foreground" data-testid="selected-region">
Selected: {selectedRegion.name ?? 'region'}
</div>
)}
</div> </div>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow /> <NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
</FloatingWindow> </FloatingWindow>
{/* Event feed, right side. */} {/* Event feed, right side by default; dockable into a left/right pane. */}
<FloatingWindow <FloatingWindow
id="feed" id="feed"
dataTestId="panel-feed"
anchor="right" anchor="right"
width={330} width={330}
title="Event feed" title="Event feed"
@@ -554,6 +801,8 @@ function GameScreen({
{game.events.length} {game.events.length}
</Badge> </Badge>
} }
dock={dockOf('feed')}
onDockChange={(m) => setDockFor('feed', m)}
collapsed={!!collapsed.feed} collapsed={!!collapsed.feed}
onCollapsedChange={(c) => setCollapsedFor('feed', c)} onCollapsedChange={(c) => setCollapsedFor('feed', c)}
bodyClassName="h-[52vh]" bodyClassName="h-[52vh]"
@@ -561,25 +810,23 @@ function GameScreen({
<EventFeed events={game.events} nations={game.nations} inWindow /> <EventFeed events={game.events} nations={game.nations} inWindow />
</FloatingWindow> </FloatingWindow>
{/* Command deck ("Your move"), bottom-center — the prominent typing spot. */} {/* Command deck ("Your move"), bottom-center by default; dockable. */}
<FloatingWindow <FloatingWindow
id="action" id="action"
dataTestId="panel-action"
anchor="bottom-center" anchor="bottom-center"
width={370} width={370}
title="Your move" title="Your move"
titleIcon={<Radar className="size-4 text-primary" />} titleIcon={<Radar className="size-4 text-primary" />}
titleExtra={ titleExtra={
<Button game.pending.length > 0 ? (
variant="secondary" <Badge variant="secondary" className="shrink-0 text-xs tabular-nums" data-testid="title-queued">
size="sm" {game.pending.length} queued
onClick={onJump} </Badge>
disabled={busy} ) : undefined
data-testid="jump-forward"
className="shrink-0"
>
{busy ? 'Simulating…' : 'Jump forward'}
</Button>
} }
dock={dockOf('action')}
onDockChange={(m) => setDockFor('action', m)}
collapsed={!!collapsed.action} collapsed={!!collapsed.action}
onCollapsedChange={(c) => setCollapsedFor('action', c)} onCollapsedChange={(c) => setCollapsedFor('action', c)}
bodyClassName="max-h-[72vh] overflow-y-auto" bodyClassName="max-h-[72vh] overflow-y-auto"
@@ -587,9 +834,11 @@ function GameScreen({
<ActionPanel <ActionPanel
game={game} game={game}
busy={busy} busy={busy}
advisorBusy={advisorBusy}
advisors={advisors} advisors={advisors}
onAction={onAction} onAddAction={onAddAction}
onChat={onChat} onAddDiplomacy={onAddDiplomacy}
onRemoveQueueItem={onRemoveQueueItem}
onAdvisor={onAdvisor} onAdvisor={onAdvisor}
onJump={onJump} onJump={onJump}
inWindow inWindow
@@ -608,39 +857,6 @@ function GameScreen({
> >
<MapLegend regions={regions} /> <MapLegend regions={regions} />
</FloatingWindow> </FloatingWindow>
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
<FloatingWindow
id="zoom"
anchor="bottom-right"
width={88}
title="Map"
titleIcon={<MapIcon className="size-4" />}
offset={{ y: -28 }}
collapsed={!!collapsed.zoom}
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
>
<div className="flex items-center justify-center gap-1.5 p-2.5">
<Button
variant="secondary"
size="sm"
onClick={() => mapApiRef.current?.zoomIn()}
aria-label="Zoom in"
data-testid="zoom-in"
>
<Plus className="size-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => mapApiRef.current?.zoomOut()}
aria-label="Zoom out"
data-testid="zoom-out"
>
<Minus className="size-4" />
</Button>
</div>
</FloatingWindow>
</div> </div>
); );
} }
+187 -51
View File
@@ -1,45 +1,75 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loader2, Radar, Send } from 'lucide-react'; import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Textarea } from './ui/textarea'; import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import type { GameState } from '@shared/types'; import type { GameState, RoundItem } from '@shared/types';
interface Props { interface Props {
game: GameState; game: GameState;
/** A turn is currently resolving (action/chat/jump) — disable those controls. */ /** A jump (the only turn LLM call) is currently in flight — disable the commit. */
busy: boolean; busy: boolean;
/**
* An advisor fetch (a separate LLM call) is in flight. Distinct from `busy`:
* it gates only the advisor UI (the "Ask advisor" button + its hint) and must
* never disable "Jump forward" or the rest of the turn flow.
*/
advisorBusy: boolean;
/** Advisor brainstorm suggestions from the last request. */ /** Advisor brainstorm suggestions from the last request. */
advisors: string[]; advisors: string[];
onAction: (action: string) => void; /** Queue a free-text action onto the pending round (no turn advance). */
onChat: (to: string, message: string) => void; onAddAction: (text: string) => void;
/** Queue a diplomatic message onto the pending round (no turn advance). */
onAddDiplomacy: (to: string, message: string) => void;
/** Remove one queued item by its index in `game.pending` (no turn advance). */
onRemoveQueueItem: (index: number) => void;
onAdvisor: (prompt?: string) => void; onAdvisor: (prompt?: string) => void;
/** Commit the whole pending round in a single LLM turn. */
onJump: () => void; onJump: () => void;
/** /**
* Render without the outer card chrome (the surrounding floating window * Render without the outer card chrome (the surrounding floating window
* provides the border, the "Your move" title, and the "Jump forward" button * provides the border and the "Your move" title). Same content, same testids.
* in its title bar). Same content, same testids.
*/ */
inWindow?: boolean; inWindow?: boolean;
} }
/** One-line, display-safe rendering of a queued item (text truncated). */
function describeItem(item: RoundItem, nationName: (id?: string) => string): string {
const who = item.kind === 'diplomacy' ? ` to ${nationName(item.to)}` : '';
const text = item.text.length > 72 ? `${item.text.slice(0, 71)}…` : item.text;
return `${who ? `${who}:` : 'Action:'} “${text}”`;
}
/** /**
* The player's command deck ("Your move"). Three tabs — Action (free-text move), * The player's command deck ("Your move"). Three tabs — Action, Diplomacy,
* Diplomacy (a message to a specific nation), Advisor (ask the LLM for ideas, * Advisor — each with a small "Add" button that queues an item onto the pending
* "Use" feeds one into the Action tab) — share one prominent Send button that * round (these never advance the turn). Below the tabs a visible pending-queue
* routes to the active tab. While a turn resolves it shows a spinner + a hint * list — each row has a small trash button that removes that item (with a
* that turns take a few seconds. A "Jump forward" button auto-simulates the * confirmation dialog) — and one prominent "Jump forward" button that commits
* world with no player move. * the whole round in a single LLM turn. While a jump is in flight the commit
* is disabled (as is queue editing) and a hint is shown; an empty jump
* (nothing queued) is allowed and advances time.
*/ */
export default function ActionPanel({ export default function ActionPanel({
game, game,
busy, busy,
advisorBusy,
advisors, advisors,
onAction, onAddAction,
onChat, onAddDiplomacy,
onRemoveQueueItem,
onAdvisor, onAdvisor,
onJump, onJump,
inWindow = false, inWindow = false,
@@ -49,23 +79,27 @@ export default function ActionPanel({
const [target, setTarget] = useState(''); const [target, setTarget] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
// Index of the queued item the user is about to remove, or null (dialog closed).
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
const others = Object.entries(game.nations) const others = Object.entries(game.nations)
.filter(([id]) => id !== game.playerNationId) .filter(([id]) => id !== game.playerNationId)
.sort(([a], [b]) => a.localeCompare(b)); .sort(([a], [b]) => a.localeCompare(b));
const submitAction = () => { const nationName = (id?: string) => (id ? (game.nations[id]?.name ?? id) : '');
const queueAction = () => {
const text = action.trim(); const text = action.trim();
if (!text || busy) return; if (!text || busy) return;
onAddAction(text);
setAction(''); setAction('');
onAction(text);
}; };
const submitChat = () => { const queueDiplomacy = () => {
const msg = message.trim(); const msg = message.trim();
if (!msg || !target || busy) return; if (!msg || !target || busy) return;
onAddDiplomacy(target, msg);
setMessage(''); setMessage('');
onChat(target, msg);
}; };
const applyIdea = (idea: string) => { const applyIdea = (idea: string) => {
@@ -73,41 +107,21 @@ export default function ActionPanel({
setTab('action'); setTab('action');
}; };
// The single Send button routes to the active tab. const pending = game.pending;
const sendDisabled = // The item the confirmation dialog is about (re-derived from the live list,
busy || // so if the queue changes while the dialog is open it tracks the current
(tab === 'action' // item at that index — or the dialog closes if the index went out of range).
? !action.trim() const removeTarget = confirmRemoveIndex !== null ? pending[confirmRemoveIndex] : undefined;
: tab === 'diplo' const queuedLabel = busy
? !message.trim() || !target
: false);
const sendLabel = busy
? 'Simulating…' ? 'Simulating…'
: tab === 'action' : `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`;
? 'Take action'
: tab === 'diplo'
? 'Send message'
: 'Ask the advisor';
const send = () => {
if (busy) return;
if (tab === 'action') submitAction();
else if (tab === 'diplo') submitChat();
else onAdvisor(prompt.trim() || undefined);
};
// In window mode the surrounding FloatingWindow shows "Your move" and the
// "Jump forward" button in its own title bar, so omit both here.
const header = inWindow ? null : ( const header = inWindow ? null : (
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Radar className="size-4 text-primary" /> <Radar className="size-4 text-primary" />
<CardTitle className="text-base">Your move</CardTitle> <CardTitle className="text-base">Your move</CardTitle>
</div> </div>
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? 'Simulating…' : 'Jump forward'}
</Button>
</CardHeader> </CardHeader>
); );
@@ -134,6 +148,15 @@ export default function ActionPanel({
rows={3} rows={3}
data-testid="action-textarea" data-testid="action-textarea"
/> />
<Button
variant="secondary"
size="sm"
onClick={queueAction}
disabled={busy || !action.trim()}
data-testid="add-action"
>
Add action
</Button>
</TabsContent> </TabsContent>
<TabsContent value="diplo" className="space-y-2"> <TabsContent value="diplo" className="space-y-2">
@@ -156,6 +179,15 @@ export default function ActionPanel({
rows={3} rows={3}
data-testid="chat-message" data-testid="chat-message"
/> />
<Button
variant="secondary"
size="sm"
onClick={queueDiplomacy}
disabled={busy || !message.trim() || !target}
data-testid="add-diplomacy"
>
Add to round
</Button>
</TabsContent> </TabsContent>
<TabsContent value="advisor" className="space-y-2"> <TabsContent value="advisor" className="space-y-2">
@@ -165,6 +197,27 @@ export default function ActionPanel({
placeholder="Optional focus — e.g. “the nuclear standoff”" placeholder="Optional focus — e.g. “the nuclear standoff”"
data-testid="advisor-prompt" data-testid="advisor-prompt"
/> />
<Button
variant="secondary"
size="sm"
onClick={() => onAdvisor(prompt.trim() || undefined)}
disabled={advisorBusy}
data-testid="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 && ( {advisors.length > 0 && (
<ul className="space-y-1.5" data-testid="advisor-list"> <ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => ( {advisors.map((a, i) => (
@@ -184,14 +237,97 @@ export default function ActionPanel({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Pending queue + the prominent commit — always visible, in any mode. */}
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit"> <div className="rounded-lg border border-border/60 bg-card/40 p-2.5" data-testid="pending-list">
{busy ? ( {pending.length === 0 ? (
<Loader2 className="size-4 animate-spin" /> <p className="text-xs text-muted-foreground" data-testid="pending-empty">
Nothing queued — add an action or a message, then jump forward.
</p>
) : ( ) : (
<Send className="size-4" /> <>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Queued this round
</span>
<Badge variant="secondary" className="text-[11px] tabular-nums">
{pending.length}
</Badge>
</div>
<ul className="space-y-1">
{pending.map((item, i) => (
<li
key={`${i}-${item.text}`}
className="flex items-center gap-2 text-sm"
data-testid="pending-item"
data-kind={item.kind}
>
{item.kind === 'action' ? (
<Zap className="size-3.5 shrink-0 text-amber-400" aria-hidden />
) : (
<MessageSquare className="size-3.5 shrink-0 text-cyan-400" aria-hidden />
)}
{item.kind === 'diplomacy' && (
<span className="shrink-0 text-xs text-muted-foreground">
→ {nationName(item.to)}
</span>
)}
<span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setConfirmRemoveIndex(i)}
disabled={busy}
title="Remove from this round"
aria-label="Remove this queued item"
data-testid="remove-queue-item"
>
<Trash2 className="size-3.5" />
</Button>
</li>
))}
</ul>
</>
)} )}
{sendLabel} </div>
{/* Confirmation for removing a queued item (never removes without asking). */}
<Dialog open={removeTarget !== undefined} onOpenChange={(open) => !open && setConfirmRemoveIndex(null)}>
<DialogContent className="max-w-sm" data-testid="remove-confirm-dialog">
<DialogHeader>
<DialogTitle>Remove queued item?</DialogTitle>
<DialogDescription>
{removeTarget ? (
<>
{describeItem(removeTarget, nationName)} will be removed from this round and
won’t be simulated on the next jump.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmRemoveIndex(null)} data-testid="remove-cancel">
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (confirmRemoveIndex !== null) onRemoveQueueItem(confirmRemoveIndex);
setConfirmRemoveIndex(null);
}}
data-testid="remove-confirm"
>
<Trash2 className="size-4" />
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
{queuedLabel}
</Button> </Button>
{busy && ( {busy && (
<p <p
+157 -35
View File
@@ -6,7 +6,7 @@ import {
type CSSProperties, type CSSProperties,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { Minus, Plus } from 'lucide-react'; import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Edge anchor used to place a window on first run (before the user drags it). */ /** Edge anchor used to place a window on first run (before the user drags it). */
@@ -19,6 +19,11 @@ export type FloatAnchor =
| 'bottom-center' | 'bottom-center'
| 'bottom-right'; | 'bottom-right';
/** A docked column edge: pinned to a screen edge and filling the viewport height. */
export type FloatDock = 'left' | 'right';
/** A dock mode: a docked edge, or `'float'` (free-floating, the default). */
export type FloatDockMode = FloatDock | 'float';
const STORAGE_PREFIX = 'history.floatwin.v1.'; const STORAGE_PREFIX = 'history.floatwin.v1.';
// Module-level z-index bookkeeping so sibling windows stack in click order: // Module-level z-index bookkeeping so sibling windows stack in click order:
@@ -26,6 +31,9 @@ const STORAGE_PREFIX = 'history.floatwin.v1.';
// raised, the shared counter advances, so the newest on top always wins. // raised, the shared counter advances, so the newest on top always wins.
const Z_BASE = 10; const Z_BASE = 10;
const Z_STEP = 10; const Z_STEP = 10;
// A pinned window always floats above every docked/floating window, no matter
// how many times those are raised.
const Z_PINNED = 100000;
let zSeq = 0; let zSeq = 0;
let zTopId: string | null = null; let zTopId: string | null = null;
/** Returns the new z-index to use, or -1 if this window is already front-most. */ /** Returns the new z-index to use, or -1 if this window is already front-most. */
@@ -118,8 +126,31 @@ function clampPos(x: number, y: number, w: number, h: number): Pos {
export interface FloatingWindowProps { export interface FloatingWindowProps {
/** Stable id — used for the localStorage key and as a data-attribute. */ /** Stable id — used for the localStorage key and as a data-attribute. */
id: string; id: string;
/** First-run placement. The last dragged position (persisted) takes over. */ /** Optional `data-testid` on the window root, for e2e targeting. */
anchor: FloatAnchor; dataTestId?: string;
/**
* First-run placement for a free-floating window; the last dragged position
* (persisted) takes over. Ignored when `pinned` or `dock` is set.
*/
anchor?: FloatAnchor;
/**
* Pin the window to the top-center of the viewport: a fixed, non-movable bar
* a few px from the top that re-centers automatically on resize, always
* floats above docked/floating panels, and never reads/writes a persisted
* position. Used for the slim header.
*/
pinned?: boolean;
/**
* Dock the window into a full-height left or right column: non-movable,
* body scrolls, never reads/writes the persisted free-floating position.
* Absent (`undefined`) = free-floating as before.
*/
dock?: FloatDock;
/**
* When provided, render dock controls (left / right / float) in the title bar
* and report the chosen mode back to the parent, which persists it.
*/
onDockChange?: (mode: FloatDockMode) => void;
/** Fixed window width in px (content is sized to it). Omit to fit content. */ /** Fixed window width in px (content is sized to it). Omit to fit content. */
width?: number; width?: number;
/** Extra px nudged away from the anchor position (e.g. clear the map attribution). */ /** Extra px nudged away from the anchor position (e.g. clear the map attribution). */
@@ -128,7 +159,7 @@ export interface FloatingWindowProps {
title?: ReactNode; title?: ReactNode;
/** Optional icon before the title. */ /** Optional icon before the title. */
titleIcon?: ReactNode; titleIcon?: ReactNode;
/** Right-side title-bar content, rendered before the collapse button. */ /** Right-side title-bar content, rendered before the dock/collapse controls. */
titleExtra?: ReactNode; titleExtra?: ReactNode;
/** Render the collapse toggle (default true). */ /** Render the collapse toggle (default true). */
collapsible?: boolean; collapsible?: boolean;
@@ -143,16 +174,27 @@ export interface FloatingWindowProps {
} }
/** /**
* A draggable, collapsible, position-persisted window that floats over the * A window that floats over the full-screen map. Three mutually-exclusive
* full-screen map. Dragging is limited to the title bar (via pointer capture) * modes, set via props (absent = free-floating):
* so inputs and buttons in the body keep working; the window is clamped so it * • **free-floating** (default) — draggable by the title bar (pointer
* can never be dragged fully off-screen, its position is remembered in * capture), clamped on-screen, position persisted in localStorage, raised
* localStorage, clicking any part of it raises it above siblings, and the * above siblings on click, collapsible.
* collapse toggle shrinks it to just its title bar. * • **pinned** — a fixed, non-movable bar centered at the top of the
* viewport a few px down; re-centers on resize; always above other
* windows; no position persisted.
* • **docked** (`left`/`right`) — a non-movable, full-height column pinned
* to a screen edge whose body scrolls; the persisted free-floating
* position is neither read nor written while docked.
* When `onDockChange` is provided, small dock/left/right/float controls render
* in the title bar and report the chosen mode back to the parent.
*/ */
export default function FloatingWindow({ export default function FloatingWindow({
id, id,
anchor, dataTestId,
anchor = 'top-center',
pinned = false,
dock,
onDockChange,
width, width,
offset, offset,
title, title,
@@ -165,6 +207,12 @@ export default function FloatingWindow({
bodyClassName, bodyClassName,
children, children,
}: FloatingWindowProps) { }: FloatingWindowProps) {
const isPinned = pinned;
const isDocked = dock === 'left' || dock === 'right';
// Free-floating is the only mode that drags, persists a position, and needs
// viewport measurement/clamping. Pinned and docked are CSS-positioned.
const isFloating = !isPinned && !isDocked;
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const sizeRef = useRef<{ w: number; h: number } | null>(null); const sizeRef = useRef<{ w: number; h: number } | null>(null);
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null); const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
@@ -177,9 +225,11 @@ export default function FloatingWindow({
setPosState(p); setPosState(p);
}; };
// First paint: measure, then place (restored position or anchor) and clamp. // First paint (free-floating only): measure, then place (restored position
// Runs before paint (useLayoutEffect), so there is no visible flash. // or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash.
// Re-runs when the mode changes so a window returning from docked re-places.
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isFloating) return;
if (posRef.current) return; if (posRef.current) return;
const el = rootRef.current; const el = rootRef.current;
if (!el) return; if (!el) return;
@@ -195,11 +245,12 @@ export default function FloatingWindow({
); );
posRef.current = p; posRef.current = p;
setPosState(p); setPosState(p);
}, [id, anchor]); }, [id, anchor, offset, isFloating]);
// Re-measure + re-clamp whenever the body toggles hidden (expand can push a // Re-measure + re-clamp whenever the body toggles hidden (expand can push a
// bottom-anchored window off the bottom edge). // bottom-anchored window off the bottom edge). Free-floating only.
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isFloating) return;
const el = rootRef.current; const el = rootRef.current;
const p = posRef.current; const p = posRef.current;
if (!el || !p) return; if (!el || !p) return;
@@ -208,10 +259,11 @@ export default function FloatingWindow({
sizeRef.current = { w: rect.width, h: rect.height }; sizeRef.current = { w: rect.width, h: rect.height };
const c = clampPos(p.x, p.y, rect.width, rect.height); const c = clampPos(p.x, p.y, rect.width, rect.height);
if (c.x !== p.x || c.y !== p.y) setPos(c); if (c.x !== p.x || c.y !== p.y) setPos(c);
}, [collapsed]); }, [collapsed, isFloating]);
// Keep the window on-screen when the viewport shrinks. // Keep a free-floating window on-screen when the viewport shrinks.
useEffect(() => { useEffect(() => {
if (!isFloating) return;
const onResize = () => { const onResize = () => {
const p = posRef.current; const p = posRef.current;
const s = sizeRef.current; const s = sizeRef.current;
@@ -221,7 +273,7 @@ export default function FloatingWindow({
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, []); }, [isFloating]);
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => { const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; if (e.button !== 0) return;
@@ -258,18 +310,44 @@ export default function FloatingWindow({
if (p) savePos(id, p.x, p.y); if (p) savePos(id, p.x, p.y);
}; };
const style: CSSProperties = { // Root placement: pinned → centered top bar; docked → full-height edge
zIndex: z, // column; free-floating → last/initial pixel position (hidden until measured).
...(pos ? { left: pos.x, top: pos.y } : {}), const rootStyle: CSSProperties = isPinned
}; ? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
: isDocked
? {
zIndex: z,
top: 0,
height: '100vh',
...(dock === 'left' ? { left: 0 } : { right: 0 }),
}
: { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) };
// The title bar is the drag handle only in free-floating mode.
const titleBarHandlers = isFloating
? {
onPointerDown: onHandleDown,
onPointerMove: onHandleMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
}
: {};
// Docked: the body fills the remaining column height and scrolls, overriding
// any max-height the caller set for the floating layout.
const bodyClass = isDocked
? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden')
: cn('min-h-0', collapsed && 'hidden', bodyClassName);
return ( return (
<div <div
ref={rootRef} ref={rootRef}
data-float-window={id} data-float-window={id}
style={style} data-testid={dataTestId}
className={cn('fixed', !pos && 'pointer-events-none opacity-0')} style={rootStyle}
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
onPointerDown={() => { onPointerDown={() => {
if (isPinned) return;
const nz = nextZ(id); const nz = nextZ(id);
if (nz > 0) setZ(nz); if (nz > 0) setZ(nz);
}} }}
@@ -277,21 +355,24 @@ export default function FloatingWindow({
<div <div
className={cn( className={cn(
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md', 'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
isDocked && 'flex h-full flex-col',
className, className,
)} )}
style={ style={
width != null isDocked
? { width, maxWidth: '96vw' } ? { width: width ?? 'fit-content', maxWidth: '96vw' }
: { width: 'fit-content', maxWidth: '96vw' } : width != null
? { width, maxWidth: '96vw' }
: { width: 'fit-content', maxWidth: '96vw' }
} }
> >
{title != null && ( {title != null && (
<div <div
className="flex cursor-grab touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5 active:cursor-grabbing" className={cn(
onPointerDown={onHandleDown} 'flex shrink-0 touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5',
onPointerMove={onHandleMove} isFloating && 'cursor-grab active:cursor-grabbing',
onPointerUp={endDrag} )}
onPointerCancel={endDrag} {...titleBarHandlers}
> >
{titleIcon && ( {titleIcon && (
<span className="shrink-0 text-muted-foreground" aria-hidden> <span className="shrink-0 text-muted-foreground" aria-hidden>
@@ -302,6 +383,49 @@ export default function FloatingWindow({
{title} {title}
</span> </span>
{titleExtra} {titleExtra}
{onDockChange && (
<span className="flex shrink-0 items-center gap-0.5" data-no-drag>
<button
type="button"
data-testid="dock-left"
aria-label="Dock to the left edge"
title="Dock to the left edge"
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
dock === 'left' && 'bg-white/10 text-foreground',
)}
onClick={() => onDockChange('left')}
>
<AlignLeft className="size-3.5" />
</button>
<button
type="button"
data-testid="dock-right"
aria-label="Dock to the right edge"
title="Dock to the right edge"
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
dock === 'right' && 'bg-white/10 text-foreground',
)}
onClick={() => onDockChange('right')}
>
<AlignRight className="size-3.5" />
</button>
<button
type="button"
data-testid="dock-float"
aria-label="Float the window"
title="Float (undock)"
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
!isDocked && 'bg-white/10 text-foreground',
)}
onClick={() => onDockChange('float')}
>
<Maximize2 className="size-3.5" />
</button>
</span>
)}
{collapsibleProp && ( {collapsibleProp && (
<button <button
type="button" type="button"
@@ -315,9 +439,7 @@ export default function FloatingWindow({
)} )}
</div> </div>
)} )}
{children != null && ( {children != null && <div className={bodyClass}>{children}</div>}
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
)}
</div> </div>
</div> </div>
); );
+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 { Badge } from './ui/badge';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import type { PresetMeta } from '@shared/types'; import type { PresetMeta } from '@shared/types';
import type { GameSummary } from '@shared/protocol'; import type { GameSummary } from '@shared/protocol';
@@ -15,12 +24,14 @@ interface Props {
activeGameId?: string | null; activeGameId?: string | null;
/** Resume a saved game by id (shared by the resume button and saved-games rows). */ /** Resume a saved game by id (shared by the resume button and saved-games rows). */
onResume?: (id: string) => void; onResume?: (id: string) => void;
/** Delete a saved game by id (confirmed by a dialog in this component). */
onDelete?: (id: string) => void;
} }
/** /**
* Home screen: a grid of scenario cards (each with a Play button), a prominent * Home screen: a grid of scenario cards (each with a Play button), a prominent
* "Resume last game" affordance, and a "Saved games" section listing prior * "Resume last game" affordance, and a "Saved games" section listing prior
* games so the user can get back into one. * games so the user can get back into one — or delete it.
*/ */
export default function PresetPicker({ export default function PresetPicker({
presets, presets,
@@ -29,8 +40,14 @@ export default function PresetPicker({
savedGames = [], savedGames = [],
activeGameId, activeGameId,
onResume, onResume,
onDelete,
}: Props) { }: Props) {
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined; const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
// The game awaiting a delete confirmation (null = dialog closed).
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const confirmDeleteTarget = confirmDeleteId
? savedGames.find((g) => g.id === confirmDeleteId)
: undefined;
return ( return (
<div className="mx-auto max-w-6xl px-6 py-10"> <div className="mx-auto max-w-6xl px-6 py-10">
@@ -90,7 +107,7 @@ export default function PresetPicker({
))} ))}
</div> </div>
{/* Saved games: get back into any prior game. */} {/* Saved games: get back into any prior game (or delete it). */}
{savedGames.length > 0 && ( {savedGames.length > 0 && (
<section className="mt-12" data-testid="saved-games"> <section className="mt-12" data-testid="saved-games">
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2> <h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
@@ -100,13 +117,28 @@ export default function PresetPicker({
key={g.id} key={g.id}
className="flex flex-col" className="flex flex-col"
data-testid="saved-game" data-testid="saved-game"
data-game-id={g.id}
> >
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{g.presetTitle}</CardTitle> <CardTitle className="text-base">{g.presetTitle}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs"> <div className="flex shrink-0 items-center gap-1">
{g.date} <Badge variant="secondary" className="text-xs">
</Badge> {g.date}
</Badge>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
data-testid="delete-game"
aria-label={`Delete ${g.presetTitle}`}
title="Delete this game"
onClick={() => setConfirmDeleteId(g.id)}
disabled={loading}
>
<Trash2 />
</Button>
</div>
</div> </div>
<CardDescription className="text-sm">Turn {g.turn}</CardDescription> <CardDescription className="text-sm">Turn {g.turn}</CardDescription>
</CardHeader> </CardHeader>
@@ -125,6 +157,40 @@ export default function PresetPicker({
</div> </div>
</section> </section>
)} )}
{/* Confirm before permanently deleting a saved game. */}
<Dialog open={confirmDeleteId !== null} onOpenChange={(o) => !o && setConfirmDeleteId(null)}>
<DialogContent className="max-w-sm" data-testid="delete-game-confirm-dialog">
<DialogHeader>
<DialogTitle>Delete this game?</DialogTitle>
<DialogDescription>
{confirmDeleteTarget
? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.`
: 'This game will be permanently removed.'}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
data-testid="delete-game-cancel"
onClick={() => setConfirmDeleteId(null)}
>
Cancel
</Button>
<Button
variant="destructive"
data-testid="delete-game-confirm"
onClick={() => {
const id = confirmDeleteId;
setConfirmDeleteId(null);
if (id) onDelete?.(id);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+168 -115
View File
@@ -8,11 +8,16 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// Point it at a Vite-emitted copy of the worker module so it loads in prod. // Point it at a Vite-emitted copy of the worker module so it loads in prod.
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url'; import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl); maplibregl.setWorkerUrl(workerUrl);
import type { RegionMap, RegionOwner } from '@shared/protocol'; import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types'; import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// 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 stable top-level `id` (ISO 3166-2 style, e.g. `FR-57`, `US-AK`) and
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
// As of v0.19 the top-level `id` is also the RegionMap key: ownership (and the
// LLM) live at sub-region granularity, so a nation owns individual sub-regions.
interface GeoFeature { interface GeoFeature {
type: 'Feature'; type: 'Feature';
id?: string | number; id?: string | number;
@@ -27,7 +32,7 @@ type GeoSource = maplibregl.GeoJSONSource;
// Inline basemap style — a visible dark-navy background plus a subtle // Inline basemap style — a visible dark-navy background plus a subtle
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain // 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. // on top and is the real star.
// //
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles // Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
@@ -61,7 +66,7 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
type: 'raster', type: 'raster',
source: 'terrain', source: 'terrain',
paint: { paint: {
'raster-opacity': 0.5, 'raster-opacity': 0.15,
'raster-brightness-max': 0.8, 'raster-brightness-max': 0.8,
'raster-fade-duration': 200, 'raster-fade-duration': 200,
}, },
@@ -69,11 +74,11 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
], ],
}; };
const COUNTRIES_SOURCE = 'countries'; const REGIONS_SOURCE = 'regions';
const HOVER_SOURCE = 'hover'; const HOVER_SOURCE = 'hover';
const FLASH_SOURCE = 'flash'; const FLASH_SOURCE = 'flash';
const FILL_LAYER = 'countries-fill'; const FILL_LAYER = 'regions-fill';
const LINE_LAYER = 'countries-line'; const LINE_LAYER = 'regions-line';
const PLAYER_LINE = 'player-line'; const PLAYER_LINE = 'player-line';
const FLASH_LINE = 'flash-line'; const FLASH_LINE = 'flash-line';
const HOVER_FILL = 'hover-fill'; const HOVER_FILL = 'hover-fill';
@@ -97,34 +102,69 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
} }
/** /**
* Stamp the current ownership + colors onto a clone of the base geo. Each region * Stamp the current ownership + colors onto a clone of the sub-national base geo.
* carries its owner's stable fill color, a subtle stroke, and a flag for the * Since v0.19 ownership and the LLM live at **sub-region** granularity: a nation
* player's own regions (rendered as a brighter, thicker border by a dedicated * owns individual sub-region ids, so each sub-region carries the color of the
* layer). Unknown owners fall back to a faint neutral fill — never an error. * nation that owns that exact sub-region (a country can now be split between
* nations, and a sub-region in no nation's territory renders grey). Each
* sub-region therefore carries:
*
* - `regionId` – its own sub-region id (e.g. `FR-57`), 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) —
* and the **RegionMap key** the owner is resolved through;
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`); a static geographic
* fact shown in the dossier label, **not** used for ownership;
* - `fill` – the owning nation's stable color (from `RegionMap[regionId]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned sub-region);
* - `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 the feature's **own
* sub-region id** (as `properties.regionId` at interaction time, via
* `mapFeatureOwner` in @shared/protocol) — **never** by the repacked top-level
* `id` (a MapLibre numeric index, #6) and **never** by the parent country code.
*/ */
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection { function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) { for (const f of out.features) {
f.properties = f.properties ?? {}; f.properties = f.properties ?? {};
const r = f.id ? regions[f.id as string] : undefined; // The sub-region's own id (e.g. `FR-57`) — stamped into a property that
// survives into the rendered features (the top-level `id` gets repacked).
const regionId = f.id == null ? '' : String(f.id);
f.properties.regionId = regionId;
// The parent country's ISO_A3 (e.g. `FRA`) — a static geographic fact used
// for the dossier label only; ownership is resolved by `regionId`.
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
f.properties.parent = parent;
const r = regionId ? regions[regionId] : undefined;
if (r) { if (r) {
f.properties.fill = r.color; f.properties.fill = r.color;
f.properties.owner = r.owner; f.properties.owner = r.owner;
f.properties.stroke = 'rgba(255,255,255,0.10)'; f.properties.ownerId = r.ownerId;
f.properties.stroke = 'rgba(255,255,255,0.30)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else { } else {
f.properties.fill = 'rgba(148,163,184,0.05)'; f.properties.fill = 'rgba(148,163,184,0.05)';
f.properties.stroke = 'rgba(255,255,255,0.06)'; f.properties.stroke = 'rgba(255,255,255,0.14)';
f.properties.owner = ''; f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0; f.properties.isPlayer = 0;
} }
} }
return out; return out;
} }
/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */ /**
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): FeatureCollection { * Build a FeatureCollection of just the sub-regions whose **own sub-region id**
const set = new Set(codes); * is in `regionIds` (cloned). Used by the territory-change flash: since v0.19
* a turn changes individual sub-regions (the RegionMap is keyed by sub-region
* id), so each changed id lights up exactly its feature — no expansion to a
* whole country needed.
*/
function featuresByRegionIds(geo: FeatureCollection, regionIds: Iterable<string>): FeatureCollection {
const set = new Set(regionIds);
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id))); const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
} }
@@ -137,72 +177,72 @@ 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>`; 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 **current owner**
* (color-chipped), and 1–2 key stats. When the region has no owning nation, a
* minimal "No owner" card is shown instead. The static parent-country code
* (`properties.parent`) is deliberately NOT printed here — it is a geographic
* fact that never updates when ownership is transferred, so only the live
* owner chip (resolved from the RegionMap by sub-region id) is shown.
*/
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) { 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 || '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 stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
return `<div class="hm-popup"> 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>
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div> <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 class="hm-stats">${stats}</div>
</div>`; </div>`;
} }
/** Click popup: the full owning-nation profile with all four stats. */
function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
const stats = [
statRow('Mil', nation.stats.military),
statRow('Eco', nation.stats.economy),
statRow('Stab', nation.stats.stability),
statRow('Pop', nation.stats.population),
].join('');
const regions = nation.territory.length;
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
<div class="hm-popup-owner hm-popup-owner-big"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
<div class="hm-popup-sub">${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier</div>
<div class="hm-stats">${stats}</div>
</div>`;
}
/** Minimal map API exposed to the floating zoom control (and any other UI). */
export interface MapApi {
zoomIn: () => void;
zoomOut: () => void;
reset: () => void;
}
interface Props { interface Props {
regions: RegionMap; regions: RegionMap;
playerNationId: string; playerNationId: string;
/** All nations, for hover/click stats and color chips. */ /** All nations, for the hover tooltip's owner stats and color chips. */
nations: Record<string, Nation>; nations: Record<string, Nation>;
/** The active scenario id, for a sensible initial camera. */ /** The active scenario id, for a sensible initial camera. */
presetId: string; presetId: string;
className?: string; className?: string;
/** Called when a region is clicked, so the dossier can focus its owner. */
onFocusNation?: (id: string) => void;
/** /**
* Called with a minimal zoom/pan API once the map is ready (and with `null` * Called with a region's owning-nation id when a sub-region is clicked, so the
* on unmount), so the floating zoom control can drive it. * dossier switches to that nation. Since v0.19 the owner is the nation that
* owns that very sub-region (resolved from the RegionMap by sub-region id).
* The optional `info` carries the clicked sub-region's own 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 * World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the * the finer `regions.geo.json` dataset: each country is subdivided into
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation * ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
* stable fills (the choropleth is the star), thin borders, a brighter/thicker * lines. Since v0.19 ownership and the LLM live at **sub-region** granularity:
* border for the player, hover + click popups with * a nation owns individual sub-region ids, so each feature is filled with the
* owner + stats, a transient flash on territory changes, and a * color of the nation owning that exact sub-region (a country can be split
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating * between nations; unowned sub-regions render grey). Rendered as a fixed,
* zoom control can drive it (no MapLibre navigation controls are shown). * full-viewport background layer (z-0) behind the floating HUD windows: a
* dark-navy basemap with subtle terrain, per-region stable fills (the
* choropleth is the star), thin sub-region borders, a brighter/thicker amber
* border for the player's regions, a hover tooltip (sub-region name + current
* owner + key stats), a **click that switches the dossier to the sub-region's
* owning nation** (no popup — see #7), a transient flash when a region's owner
* changes (lighting up exactly the changed features), and a per-scenario
* initial camera.
*
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
* RegionMap by the feature's **own sub-region id** (`properties.regionId`, via
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
* `id` (MapLibre repacks it into a numeric index at interaction time, #6).
* Unowned sub-regions (no RegionMap entry for the id) simply do not change the
* dossier.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API.
*/ */
export default function WorldMap({ export default function WorldMap({
regions, regions,
@@ -211,7 +251,6 @@ export default function WorldMap({
presetId, presetId,
className, className,
onFocusNation, onFocusNation,
onMapReady,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
@@ -222,13 +261,11 @@ export default function WorldMap({
const playerRef = useRef(playerNationId); const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId); const presetRef = useRef(presetId);
const onFocusNationRef = useRef(onFocusNation); const onFocusNationRef = useRef(onFocusNation);
const onMapReadyRef = useRef(onMapReady);
regionsRef.current = regions; regionsRef.current = regions;
nationsRef.current = nations; nationsRef.current = nations;
playerRef.current = playerNationId; playerRef.current = playerNationId;
presetRef.current = presetId; presetRef.current = presetId;
onFocusNationRef.current = onFocusNation; onFocusNationRef.current = onFocusNation;
onMapReadyRef.current = onMapReady;
// Diffing state for the transient territory-change flash (owned by this component // Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes). // so a resync with identical data never re-flashes).
@@ -257,27 +294,15 @@ export default function WorldMap({
// Purely for testability; has no effect on rendering or behavior. // Purely for testability; has no effect on rendering or behavior.
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map; (containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map;
// Expose a tiny zoom/pan API to the floating control (null on unmount).
const api: MapApi = {
zoomIn: () => map.zoomIn(),
zoomOut: () => map.zoomOut(),
reset: () => {
const c = cameraForPreset(presetRef.current);
map.easeTo({ center: c.center, zoom: c.zoom, duration: 600 });
},
};
onMapReadyRef.current?.(api);
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
let lastHoverId: string | null = null; let lastHoverId: string | null = null;
const loadGeo = (): Promise<FeatureCollection> => const loadGeo = (): Promise<FeatureCollection> =>
baseGeoRef.current baseGeoRef.current
? Promise.resolve(baseGeoRef.current) ? Promise.resolve(baseGeoRef.current)
: fetch('/countries.geo.json') : fetch('/regions.geo.json')
.then((r) => { .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(); return r.json();
}) })
.then((g: FeatureCollection) => { .then((g: FeatureCollection) => {
@@ -288,12 +313,14 @@ export default function WorldMap({
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] }); const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
// Transient flash: pulse a bright outline over the changed regions, then fade // 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 sub-region ids (the RegionMap keys that changed owner since the last
// update); each lights up exactly its feature.
function animateFlash(codes: string[]) { function animateFlash(codes: string[]) {
const geo = baseGeoRef.current; const geo = baseGeoRef.current;
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
if (!geo || !src || cancelled) return; if (!geo || !src || cancelled) return;
src.setData(featuresByCodes(geo, codes)); src.setData(featuresByRegionIds(geo, codes));
if (!map.getLayer(FLASH_LINE)) { if (!map.getLayer(FLASH_LINE)) {
// Defensive re-add (normally created by ensureLayers). Keep it beneath the // Defensive re-add (normally created by ensureLayers). Keep it beneath the
// hover highlight when that layer exists; otherwise just append. // hover highlight when that layer exists; otherwise just append.
@@ -326,24 +353,33 @@ export default function WorldMap({
} }
flashApiRef.current = animateFlash; 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 that
// same `properties.regionId` through `mapFeatureOwner` — since v0.19 the
// RegionMap is keyed by the sub-region's own id. Never the repacked
// top-level `id` (a numeric index, #6).
const onHover = (e: maplibregl.MapLayerMouseEvent) => { const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0]; const f = e.features?.[0];
if (!f) return; if (!f) return;
const id = f.id == null ? null : String(f.id); const p = f.properties as { name?: string; regionId?: string };
if (id && id !== lastHoverId) { const subId = typeof p?.regionId === 'string' ? p.regionId : '';
lastHoverId = id; if (subId && subId !== lastHoverId) {
lastHoverId = subId;
const geo = baseGeoRef.current; const geo = baseGeoRef.current;
const feat = geo?.features.find((x) => x.id != null && String(x.id) === id); // The base GeoJSON feature's top-level `id` IS the 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( (map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(), feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
); );
} }
const p = f.properties as { name?: string }; const region = mapFeatureOwner(regionsRef.current, f);
const region = id ? regionsRef.current[id] : undefined;
const nation = region ? nationsRef.current[region.ownerId] : undefined; const nation = region ? nationsRef.current[region.ownerId] : undefined;
hoverPopup hoverPopup
.setLngLat(e.lngLat) .setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? id ?? '', region, nation)) .setHTML(hoverHtml(p?.name ?? subId, region, nation))
.addTo(map); .addTo(map);
map.getCanvas().style.cursor = 'pointer'; map.getCanvas().style.cursor = 'pointer';
}; };
@@ -355,15 +391,24 @@ export default function WorldMap({
map.getCanvas().style.cursor = ''; map.getCanvas().style.cursor = '';
}; };
// #7: a click does NOT open a popup — it switches the dossier to the region's
// owning nation. Since v0.19 the owner is the nation that owns **this**
// sub-region, resolved from `properties.regionId` via the RegionMap (never
// the repacked top-level `f.id`, #6). A sub-region with no owner leaves the
// current dossier target unchanged — never a broken dossier.
const onClick = (e: maplibregl.MapLayerMouseEvent) => { const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0]; const f = e.features?.[0];
if (!f) return; if (!f) return;
const id = f.id == null ? null : String(f.id); const region = mapFeatureOwner(regionsRef.current, f);
const p = f.properties as { name?: string }; if (!region?.ownerId) return;
const region = id ? regionsRef.current[id] : undefined; // Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
const nation = region ? nationsRef.current[region.ownerId] : undefined; // the dossier can show which region was selected.
clickPopup.setLngLat(e.lngLat).setHTML(clickHtml(p?.name ?? id ?? '', region, nation)).addTo(map); const p = f.properties as { regionId?: string; parent?: string; name?: string };
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId); 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 // Idempotent: (re)creates the sources + layers after any style (re)load and
@@ -371,16 +416,16 @@ export default function WorldMap({
// automatically, so no setWorkerUrl is needed.) // automatically, so no setWorkerUrl is needed.)
// //
// The three choropleth sources are created up front (empty until the base // The three choropleth sources are created up front (empty until the base
// geo arrives) so the layer structure always exists; `paintCountries` then // geo arrives) so the layer structure always exists; `paintRegions` then
// stamps ownership + colors onto the countries source. This makes it immune // stamps ownership + colors onto the regions source. This makes it immune
// to load-order races: if `ensureLayers` runs before the geo fetch // 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, the data is applied the moment the fetch lands; if the geo
// resolves first, `ensureLayers` uses it. No source is ever left stuck on // resolves first, `ensureLayers` uses it. No source is ever left stuck on
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the // `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
// regions effect re-paints whenever ownership changes. // regions effect re-paints whenever ownership changes.
const ensureSources = () => { const ensureSources = () => {
if (!map.getSource(COUNTRIES_SOURCE)) { if (!map.getSource(REGIONS_SOURCE)) {
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() }); map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() });
} }
if (!map.getSource(HOVER_SOURCE)) { if (!map.getSource(HOVER_SOURCE)) {
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() }); map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
@@ -390,10 +435,10 @@ export default function WorldMap({
} }
}; };
// Stamp ownership + colors onto the countries source (no-op until geo loads). // Stamp ownership + colors onto the regions source (no-op until geo loads).
const paintCountries = () => { const paintRegions = () => {
const geo = baseGeoRef.current; 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; if (!geo || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current)); src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
}; };
@@ -402,32 +447,39 @@ export default function WorldMap({
if (cancelled) return; if (cancelled) return;
ensureSources(); ensureSources();
if (baseGeoRef.current) { if (baseGeoRef.current) {
paintCountries(); paintRegions();
} else { } else {
try { try {
await loadGeo(); await loadGeo();
} catch (err) { } catch (err) {
console.error('failed to load countries geojson', err); console.error('failed to load regions geojson', err);
} }
if (cancelled) return; if (cancelled) return;
paintCountries(); paintRegions();
} }
if (!map.getLayer(FILL_LAYER)) { if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation // The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain. // colors read clearly even on the dark background / over the terrain.
// Since v0.19 each sub-region carries the color of the nation owning
// that exact sub-region, so a country taken at region level shows the
// split directly in the fill.
map.addLayer({ map.addLayer({
id: FILL_LAYER, id: FILL_LAYER,
type: 'fill', type: 'fill',
source: COUNTRIES_SOURCE, source: REGIONS_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 }, paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
}); });
} }
if (!map.getLayer(LINE_LAYER)) { if (!map.getLayer(LINE_LAYER)) {
// 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({ map.addLayer({
id: LINE_LAYER, id: LINE_LAYER,
type: 'line', type: 'line',
source: COUNTRIES_SOURCE, source: REGIONS_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 }, 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 // NOTE: layers are added in bottom→top order (each appends on top). The
@@ -438,7 +490,7 @@ export default function WorldMap({
map.addLayer({ map.addLayer({
id: PLAYER_LINE, id: PLAYER_LINE,
type: 'line', type: 'line',
source: COUNTRIES_SOURCE, source: REGIONS_SOURCE,
filter: ['==', ['get', 'isPlayer'], 1], filter: ['==', ['get', 'isPlayer'], 1],
paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 }, paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 },
}); });
@@ -482,7 +534,6 @@ export default function WorldMap({
cancelled = true; cancelled = true;
cancelAnimationFrame(flashRaf); cancelAnimationFrame(flashRaf);
flashApiRef.current = null; flashApiRef.current = null;
onMapReadyRef.current?.(null);
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
if (containerRef.current) { if (containerRef.current) {
@@ -491,8 +542,10 @@ export default function WorldMap({
}; };
}, []); }, []);
// Re-color the choropleth whenever ownership changes, and flash any regions // Re-color the choropleth whenever ownership changes, and flash any sub-region
// whose owner changed since the last update (transient, self-cleaning). // whose owner changed since the last update (a transient, self-cleaning pulse).
// The diff is over the sub-region-id-keyed RegionMap; `animateFlash` lights up
// exactly the features whose own id changed owner.
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
const geo = baseGeoRef.current; const geo = baseGeoRef.current;
@@ -503,14 +556,14 @@ export default function WorldMap({
if (Object.keys(cur).length > 0) primedRef.current = true; if (Object.keys(cur).length > 0) primedRef.current = true;
if (map && geo) { 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)); if (src) void src.setData(applyRegions(geo, cur, playerNationId));
} }
if (wasPrimed && prev) { if (wasPrimed && prev) {
const changed = new Set<string>(); const changed = new Set<string>();
for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) { for (const regionId of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code); if ((prev[regionId]?.ownerId ?? '') !== (cur[regionId]?.ownerId ?? '')) changed.add(regionId);
} }
if (changed.size > 0) flashApiRef.current?.([...changed]); if (changed.size > 0) flashApiRef.current?.([...changed]);
} }
+32 -4
View File
@@ -2,7 +2,16 @@
// proxied by Vite in dev and served by Express in prod), so no origin is ever // proxied by Vite in dev and served by Express in prod), so no origin is ever
// hardcoded — this works unchanged behind the Traefik proxy. // hardcoded — this works unchanged behind the Traefik proxy.
import type { GameState, Preset, PresetMeta } from '@shared/types'; import type { GameState, Preset, PresetMeta } from '@shared/types';
import type { AdvisorResponse, CreateGameResponse, GameSummary, TurnResponse } from '@shared/protocol'; import type {
AddActionResponse,
AddDiplomacyResponse,
AdvisorResponse,
CreateGameResponse,
DeleteGameResponse,
GameSummary,
RemoveQueueItemResponse,
TurnResponse,
} from '@shared/protocol';
async function req<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
@@ -25,14 +34,33 @@ export const api = {
body: JSON.stringify({ presetId, playerNationId }), body: JSON.stringify({ presetId, playerNationId }),
}), }),
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`), getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
// Delete a saved game entirely. Fast, non-LLM. Returns { deleted: true }.
deleteGame: (id: string) =>
req<DeleteGameResponse>(`/api/games/${id}`, { method: 'DELETE' }),
// Every saved game as a lightweight summary, newest-updated first (for the // Every saved game as a lightweight summary, newest-updated first (for the
// home screen's "Saved games" section / resume affordances). // home screen's "Saved games" section / resume affordances).
listGames: () => req<{ games: GameSummary[] }>('/api/games'), listGames: () => req<{ games: GameSummary[] }>('/api/games'),
turn: (id: string, action?: string, chat?: { to: string; message: string }) => // Queue a free-text player action onto the pending round. No LLM call — the
req<TurnResponse>(`/api/games/${id}/turn`, { // round is only committed by `jump`. Returns the updated game (with pending).
addAction: (id: string, text: string) =>
req<AddActionResponse>(`/api/games/${id}/action`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ action, chat }), body: JSON.stringify({ text }),
}), }),
// Queue a diplomatic message to a target nation onto the pending round.
addDiplomacy: (id: string, to: string, message: string) =>
req<AddDiplomacyResponse>(`/api/games/${id}/diplomacy`, {
method: 'POST',
body: JSON.stringify({ to, message }),
}),
// Remove one queued item from the pending round, by its index in `game.pending`.
// Fast, non-LLM — no turn advance. Returns the updated game.
removeQueueItem: (id: string, index: number) =>
req<RemoveQueueItemResponse>(`/api/games/${id}/queue/remove`, {
method: 'POST',
body: JSON.stringify({ index }),
}),
// Commit the whole pending round (or an empty one) in a single LLM turn.
jump: (id: string) => jump: (id: string) =>
req<TurnResponse>(`/api/games/${id}/jump`, { req<TurnResponse>(`/api/games/${id}/jump`, {
method: 'POST', method: 'POST',
+17
View File
@@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol';
interface SseHandlers { interface SseHandlers {
onState?: (state: GameState) => void; onState?: (state: GameState) => void;
onTurn?: (state: GameState, result: TurnResult) => void; onTurn?: (state: GameState, result: TurnResult) => void;
/**
* Fired for a server-sent NAMED `event: error` (a MessageEvent with a JSON
* body `{ message }`) — e.g. the game was deleted or no longer exists. Pure
* connection errors (no data) are ignored and EventSource auto-reconnects.
*/
onError?: (message: string) => void;
} }
/** /**
@@ -37,8 +43,19 @@ export function useSse(id: string | null, handlers: SseHandlers): void {
/* ignore malformed */ /* ignore malformed */
} }
}; };
const onError = (e: Event) => {
if (e instanceof MessageEvent && e.data) {
try {
const d = JSON.parse(e.data) as { message?: string };
if (d.message) handlersRef.current.onError?.(d.message);
} catch {
/* ignore malformed */
}
}
};
es.addEventListener('state', onState); es.addEventListener('state', onState);
es.addEventListener('turn', onTurn); es.addEventListener('turn', onTurn);
es.addEventListener('error', onError);
return () => es.close(); return () => es.close();
}, [id]); }, [id]);
} }
+1 -1
View File
@@ -20,5 +20,5 @@
"@/*": ["./src/*"] "@/*": ["./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: { test: {
environment: 'node', environment: 'node',
include: ['server/**/*.test.ts'], include: ['server/**/*.test.ts', 'scripts/**/*.test.ts'],
testTimeout: 20000, testTimeout: 20000,
hookTimeout: 20000, hookTimeout: 20000,
}, },