Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d201135b3 | ||
|
|
8730a56170 | ||
|
|
b6dcd3f979 | ||
|
|
31182c8730 | ||
|
|
0fd8981452 | ||
|
|
b237fe8dd4 | ||
|
|
dd9fd6a09d | ||
|
|
b5ea8ba5a4 | ||
|
|
ed91735c8a | ||
|
|
8eeb6df284 | ||
|
|
f45c01b8d6 | ||
|
|
b5e2229ee2 | ||
|
|
38d3202472 | ||
|
|
58c56024bc | ||
|
|
3c187c6901 | ||
|
|
8eea17148b | ||
|
|
073e916767 | ||
|
|
396702b9da | ||
|
|
832957c635 | ||
|
|
68402694c5 | ||
|
|
2191514db1 | ||
|
|
5d194636d0 | ||
|
|
a93ece9474 | ||
|
|
44e54470e2 | ||
|
|
cdabf16052 | ||
|
|
7fbc00ebbe | ||
|
|
22805a3be6 | ||
|
|
327fffb789 | ||
|
|
a129c2ab81 | ||
|
|
aa1bd3c5aa | ||
|
|
f6ad554823 | ||
|
|
867b2eb0a1 | ||
|
|
8fb8984d07 | ||
|
|
f593af5ba5 | ||
|
|
c63f6acca1 | ||
|
|
c0c620a54c | ||
|
|
7eaed42d51 | ||
|
|
14c5d4881d | ||
|
|
458e9be419 | ||
|
|
f72a59bf5e | ||
|
|
6ebe21591e | ||
|
|
9f230606c4 | ||
|
|
e9fa267fd0 | ||
|
|
edaa8f55d1 | ||
|
|
6c52e10898 | ||
|
|
764f24fc55 | ||
|
|
cb426273a9 | ||
|
|
0b999f1448 | ||
|
|
5abbebc58f | ||
|
|
f53f9072f4 | ||
|
|
6f14a4e4c0 | ||
|
|
6949739956 | ||
|
|
cf7d3cb122 | ||
|
|
99ee93f6da | ||
|
|
b9ac79c57f | ||
|
|
1c64aa1da4 | ||
|
|
29927bae34 | ||
|
|
325bdc5255 | ||
|
|
2cfa2f78f9 | ||
|
|
2b84c34f41 | ||
|
|
9c29779572 | ||
|
|
3cd8dcecfa | ||
|
|
acb4f0800f | ||
|
|
b1081eefde | ||
|
|
e92bf47d7d | ||
|
|
f1cc4fbc10 | ||
|
|
97702940cd | ||
|
|
8eedc37e05 | ||
|
|
69782aad69 | ||
|
|
7bf11fa5b9 | ||
|
|
db46e1a5ec | ||
|
|
99188b5b15 | ||
|
|
0ebdb36daa | ||
|
|
06cdbf3d81 | ||
|
|
344b5b4a5e | ||
|
|
d5285ce28d | ||
|
|
8252b665b5 | ||
|
|
7d50cba061 | ||
|
|
e70f65f112 | ||
|
|
e38cc5933f | ||
|
|
858b901b8d | ||
|
|
17d6cecab9 | ||
|
|
f05ddb304d | ||
|
|
eadae4dc4f | ||
|
|
67f48fb99c | ||
|
|
c19db2c118 | ||
|
|
977bf584ce | ||
|
|
6b167cb435 |
@@ -27,11 +27,24 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
|
|||||||
- Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui
|
- 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` (1,800 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||||||
|
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
|
||||||
|
(v0.19, #16 P1): every sub-region is addressed by a stable key from
|
||||||
|
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
|
||||||
|
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
|
||||||
|
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
|
||||||
|
function-calling tool) instead of whole countries. Since v0.25 (#31) the old RU-ARK
|
||||||
|
mega-region is split into the mainland (`RU-ARK`, key unchanged) plus the New Earth
|
||||||
|
(`RU-ARK-NE`) and Severnaya Zemlya (`RU-ARK-SZ`) island groups; since v0.26 (#41) the
|
||||||
|
mainland's 28-part Arctic-coast band is further re-partitioned into Pechora (`RU-PEO`)
|
||||||
|
and Yamal (`RU-YAM`) (the 5-part lat < 68 core stays `RU-ARK`/`arkhangel-sk-lhbk`).
|
||||||
- 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`), with two entry points: plain
|
||||||
(`LLM_STUB=1`) is used by all tests/e2e.
|
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
||||||
|
rounds, then one forced no-tools request) sharing the same retry/backoff/
|
||||||
|
`reasoning_effort` machinery (see the LLM config note below). A deterministic **stub**
|
||||||
|
transport (`LLM_STUB=1`) is used by all tests/e2e.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -43,23 +56,46 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||||
├── vitest.config.ts # unit tests (server/**/*.test.ts)
|
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 407 tests)
|
||||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── 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 # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||||
|
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
|
||||||
|
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
||||||
|
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||||
|
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
|
||||||
|
│ ├── region-resources.json # region key -> synthesized {oil, iron, food, industry} (each 0–1, 2dp), 1,800 entries (the resource profiles that scale industry boosts + feed the prompt aggregate, #59)
|
||||||
|
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
|
||||||
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
|
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||||
|
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
|
||||||
|
│ │ # → build-region-resources
|
||||||
|
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
|
||||||
|
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
|
||||||
|
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
|
||||||
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
|
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||||
|
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||||
|
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||||
|
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
|
||||||
|
│ # seedRegionStats / migrateRegionStats)
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||||
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||||
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
|
│ │ # PORT/LLM_*/DATA_DIR
|
||||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
|
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3; per-region stats maintenance + derived recompute, v0.28 #46) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||||
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
|
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
||||||
|
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency/centroids/geo-manifest (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency, centroidOf/hasCentroids, geoOf/hasGeo (#46)
|
||||||
|
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||||
|
│ ├── map.ts # GameState → RegionMap (region key → 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 (+ legacy regionStats migration on load, v0.28 #46)
|
||||||
│ └── *.test.ts # unit tests (vitest)
|
│ └── *.test.ts # unit tests (vitest)
|
||||||
└── src/
|
└── src/
|
||||||
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
||||||
@@ -72,10 +108,12 @@ history/
|
|||||||
│ └── utils.ts # cn()
|
│ └── utils.ts # cn()
|
||||||
└── components/
|
└── components/
|
||||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
|
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
||||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
|
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a)
|
||||||
|
├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
|
||||||
├── EventFeed.tsx # scrolling event log (newest first)
|
├── EventFeed.tsx # scrolling event log (newest first)
|
||||||
|
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
||||||
├── StatBar.tsx
|
├── StatBar.tsx
|
||||||
└── ui/ # 14 shadcn/ui primitives
|
└── ui/ # 14 shadcn/ui primitives
|
||||||
```
|
```
|
||||||
@@ -87,10 +125,16 @@ 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/` (39 tests) |
|
| `npm test` | vitest unit tests (`server/` + `scripts/`; 225 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) |
|
||||||
|
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
|
||||||
|
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||||
|
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||||
|
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
|
||||||
|
| `npm run build:region-resources` | (re)generate `public/region-resources.json` (region key → synthesized `{ oil, iron, food, industry }` 0–1 profile) from `region-keys.json` + `region-geo.json` (area factor) — deterministic + idempotent (byte-identical re-run; #59) |
|
||||||
|
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
|
||||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||||
|
|
||||||
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
||||||
@@ -119,6 +163,10 @@ history/
|
|||||||
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
|
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
|
||||||
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
|
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
|
||||||
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
||||||
|
- **Advisor "thinking" state (separate from the turn `busy`).** The Advisor tab shows a thinking hint
|
||||||
|
(mirroring the turn's `sim-hint`) and disables its "Ask advisor" button while the advisor LLM call is
|
||||||
|
in flight, and the top-bar `status-indicator` reads "Advisor thinking…". It is a distinct `advisorBusy`
|
||||||
|
flag in `App.tsx` and must never gate "Jump forward" or the turn flow.
|
||||||
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
||||||
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
||||||
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
||||||
@@ -135,13 +183,305 @@ 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.
|
||||||
|
- **Region-level territory (v0.19/v0.21, #16 P1+P2).** Since v0.19 ownership lives at sub-region
|
||||||
|
granularity: `Nation.territory` holds **region keys** (`<name-slug>-<4char>`, e.g. `moselle-rygy`;
|
||||||
|
the manifest is `public/region-keys.json`), `server/regions.ts` expands each preset's country list
|
||||||
|
to all its sub-regions' keys at game creation, and `buildRegionMap` keys the choropleth `RegionMap`
|
||||||
|
by region key. Since v0.21 transfers are **frontier-biased**: `applyTurnResult` is snapshot-first
|
||||||
|
and enforces **sole ownership** (a key transfers only when exactly one nation claims it to gain,
|
||||||
|
it has exactly one current owner, and the owner is not the gainer — a double claim is rejected and
|
||||||
|
a lone `lose` is a no-op, so a key can never be orphaned or duplicated), and the LLM can call the
|
||||||
|
`getNeighbours` tool (`GET_NEIGHBOURS_TOOL` + `makeNeighboursExecutor` in `engine.ts`, data from
|
||||||
|
`public/region-adjacency.json` via `neighboursOf`) to verify land borders before proposing a
|
||||||
|
transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering
|
||||||
|
the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the
|
||||||
|
adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain
|
||||||
|
`chat()` path and the tool/frontier simply switch off. Since v0.22 (#16 P3) a key may also end
|
||||||
|
a turn **unowned** — "no man's land": `TurnResult.unowned` lists keys released this turn;
|
||||||
|
`applyTurnResult` applies releases FIRST (a key leaves its sole owner — only a key with exactly
|
||||||
|
one current owner can be released; a never-assigned or corrupt key is a no-op), then claims
|
||||||
|
against the post-release snapshot, so a `gain` on an ownerless key is a **re-claim** (granted to
|
||||||
|
the sole gainer and removed from the cumulative `GameState.unowned` list). The user payload
|
||||||
|
carries the current `GameState.unowned` as `unowned`, and the jump route's `regions` reference
|
||||||
|
table includes those keys so the model can name them when re-claiming. The map renders a
|
||||||
|
released key in the distinct visible grey (`NO_MANS_LAND_FILL` in `shared/protocol.ts`, with a
|
||||||
|
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
||||||
|
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
||||||
|
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
||||||
|
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). Since v0.25
|
||||||
|
(#31) the 78-part RU-ARK mega-region is split into three features — mainland `RU-ARK` (its key
|
||||||
|
`arkhangel-sk-lhbk` is **unchanged**, so existing ownership is safe), `RU-ARK-NE` (New Earth /
|
||||||
|
Franz Josef Land, `new-earth-eikt`) and `RU-ARK-SZ` (Severnaya Zemlya,
|
||||||
|
`severnaya-zemlya-awqp`) — by the idempotent `scripts/split-arkhangel.mjs` stage (run between
|
||||||
|
`fill:regions` and `build:region-keys`, no-op once applied); the two island groups get their own
|
||||||
|
region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya
|
||||||
|
Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair).
|
||||||
|
Since v0.26 (#41) the mainland's 28-part Arctic-coast band (centroid lat ≥ 68) is further
|
||||||
|
re-partitioned by the idempotent `scripts/split-arkhangel-band.mjs` stage (run between
|
||||||
|
`split:regions` and `build:region-keys`, no-op once applied) into Pechora (`RU-PEO`,
|
||||||
|
`pechora-qwhs`, 13 parts) and Yamal (`RU-YAM`, `yamal-gosw`, 15 parts) — a pure re-partition
|
||||||
|
(no geometry moved → the union is byte-identical) that keeps `RU-ARK`'s id/name/key
|
||||||
|
`arkhangel-sk-lhbk` stable and inserts the two new features immediately after `RU-ARK-SZ`; the
|
||||||
|
5-part lat < 68 core stays `RU-ARK` (a single ~305k km² polygon — splitting it needs turf
|
||||||
|
clipping, not a pure re-partition).
|
||||||
|
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
|
||||||
|
`TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
|
||||||
|
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
|
||||||
|
(P4b implemented split/merge). Each op names its target via `nationRef` — the LLM may use a
|
||||||
|
nation’s **id or name**; `resolveNationRef` resolves id-first, then a case-insensitive
|
||||||
|
unique-name match, and returns `null` for an unknown or ambiguous ref (such ops are dropped
|
||||||
|
defensively in `validateNationOps`, never thrown). A turn’s ops are appended to
|
||||||
|
`GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals
|
||||||
|
persist across turns until resolved (like the action queue). The player then decides per op via
|
||||||
|
`POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?, playerPart?}}`) or
|
||||||
|
`.../nation-op/reject` (`{index}`): `confirmNationOp` applies the op to a clone — **rename** sets
|
||||||
|
`Nation.name` to the edited value or, failing that, the proposed `to` (id / territory / relations /
|
||||||
|
`playerNationId` stay valid, so a renamed player is re-named in place); **split** (the parent
|
||||||
|
must resolve and have ≥2 named parts, else a no-op) partitions the parent’s region-key territory
|
||||||
|
into new nations in parts order — each new nation gets a fresh id (`freshNationId`: first free of
|
||||||
|
`n1`, `n2`, …) and the baseline `NEW_NATION_STATS` (not a copy of the parent’s), plus a copy of
|
||||||
|
the parent’s relations; a key named by several parts goes to the first, and the parent’s
|
||||||
|
unclaimed keys go to part 0 (in territory order), so sole ownership holds and nothing is
|
||||||
|
orphaned; if the parent was the player’s nation, `playerNationId` moves to the part chosen by
|
||||||
|
`edits.playerPart` (clamped into range, default 0), which also receives `edits.name`; **merge**
|
||||||
|
(every ref must resolve, ≥2 DISTINCT inputs, and the survivor must be one of them, else a
|
||||||
|
no-op) folds the inputs into the survivor, which keeps its id and its own stats, gains the
|
||||||
|
union of the inputs’ territories (the survivor’s keys first) and a union of their relations
|
||||||
|
(the survivor’s own view of a third party wins, else the first input’s), is renamed
|
||||||
|
`edits.name` → `into` → its own name, while the other inputs are removed and their ids dropped
|
||||||
|
from every remaining nation’s relations maps; if the player led a removed input,
|
||||||
|
`playerNationId` moves to the survivor. An op that is invalid at confirm time (an ambiguous ref,
|
||||||
|
…) is a no-op on the world but is still removed — and `rejectNationOp` just drops it; both
|
||||||
|
splice by index and bump `version` once (an invalid index → `null` → HTTP 400). The response is
|
||||||
|
`{ game }` and the server broadcasts a version-bumped SSE `state`, so every tab’s
|
||||||
|
version-guarded `applyState` stays in sync. The client lists the pending ops in a “Nation
|
||||||
|
proposals” dialog (in the “Your move” panel): a rename and a merge show an editable name field
|
||||||
|
(the merge also highlights the survivor among the input chips); a split lists each part (name +
|
||||||
|
proposed region count) with a successor radio that appears only when the parent is the player’s
|
||||||
|
own nation (preselected part 0 — the choice is always valid). The system prompt documents all
|
||||||
|
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
|
||||||
|
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
|
||||||
|
(`server/engine.test.ts`).
|
||||||
|
- **Map markers (v0.27, #44) — LLM-placed named points, region-anchored, canvas-icon rendered.**
|
||||||
|
The LLM can place named **point markers** on the map each turn: eight `MarkerType`s
|
||||||
|
(`city, factory, army, port, capital, fleet, rocket, gathering`; a **capital** is at most one
|
||||||
|
per nation, a **gathering** a political/social mass event that can enable a non-military
|
||||||
|
takeover). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required,
|
||||||
|
default `[]`; `MarkerSpec = { type, name, regionKey }`). A marker's stable `id` is the FNV-1a
|
||||||
|
32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase
|
||||||
|
base-26 characters (mirroring the region-key scheme) — so a **re-add is idempotent** and a
|
||||||
|
**remove is unambiguous** (by id). `GameState.markers: MapMarker[]` is required (`[]` at game
|
||||||
|
creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||||
|
`applyTurnResult` handles markers **after** territory claims: (1) remove by id (absent id →
|
||||||
|
no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim
|
||||||
|
ownership** — a marker always follows its region (a captured region follows its new owner; a
|
||||||
|
region released to no man's land, or a key with 0 or 2+ owners, becomes `null`); (3) add — each
|
||||||
|
new spec is placed at its region's centroid (from `public/region-centroids.json`, injected via
|
||||||
|
the optional `centroidOf` param on `applyTurnResult`) plus a deterministic per-axis jitter
|
||||||
|
(`spread ≈ 0.1 × the largest part's smallest bbox dimension`, so multiple markers of one region
|
||||||
|
scatter instead of stacking), owned by the region's post-turn owner, stamped with the acting
|
||||||
|
turn. Adds are **dropped, never thrown**, when the region key is unknown or has no centroid,
|
||||||
|
when the id already exists, or beyond the hard cap `MAX_MARKERS = 400`. The system prompt
|
||||||
|
documents the field + types + placement rules; `buildUserPrompt` carries the current markers
|
||||||
|
(as `MarkerInfo[]`) so the model can reference their ids in `remove`; a marker add/remove makes
|
||||||
|
a turn "meaningful". **Rendering is canvas-generated per-type ICON images, NOT text labels**
|
||||||
|
(`src/components/WorldMap.tsx`): every external glyph/font endpoint is a dead 404 (OpenFreeMap's
|
||||||
|
`fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`) and the OFM dark basemap only
|
||||||
|
renders because it is **raster** (labels baked into the tiles) — there is no working vector
|
||||||
|
font, so markers are drawn as self-contained, offline, deterministic per-type icons
|
||||||
|
(`drawMarkerIcon`: distinct shape per type, filled with the type color, outlined for legibility),
|
||||||
|
registered as named `ImageData` images that survive `styledata`. The marker **name shows in the
|
||||||
|
hover tooltip** (not a persistent on-map label), and **clicking a marker focuses its owning
|
||||||
|
nation's dossier**. There are **no separate marker event handlers** — MapLibre's per-layer event
|
||||||
|
delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead
|
||||||
|
`queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
|
||||||
|
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
|
||||||
|
unaffected and the marker path is unit-tested only (`server/engine.test.ts`,
|
||||||
|
`scripts/region-centroids.test.ts`, `server/regions.test.ts`, `server/db.test.ts`).
|
||||||
|
- **Per-region stats — `GameState.regionStats`, derived `Nation.stats` (v0.28, #46; PR 1 of 2 — the LLM lever is PR 2).**
|
||||||
|
Since v0.28 the four 0–100 stats live at **region-key** granularity: `GameState.regionStats`
|
||||||
|
(required; `{}` at game creation) maps each owned region key to its own `RegionStats`, and
|
||||||
|
`Nation.stats` is **derived** — the **area×population-weighted median** of the stats of the
|
||||||
|
regions the nation owns (`deriveNationStats`, pure in `shared/protocol.ts`). A region's weight
|
||||||
|
is `(area>0?area:1)×(population>0?population:1)` from the committed `public/region-geo.json`
|
||||||
|
manifest (1,800 entries; area in km² at 2dp, integer population) — which is built by
|
||||||
|
`scripts/build-region-geo.mjs` from `regions.geo.json` + `region-keys.json` + the committed
|
||||||
|
`public/ne-admin0-pop.json` (Natural Earth admin-0 `POP_EST`, 249 codes, one-time distilled by
|
||||||
|
`scripts/extract-admin0-pop.mjs`; each country's `POP_EST` is spread over its sub-regions
|
||||||
|
proportional to area by largest-remainder, so per-country totals are preserved exactly). A
|
||||||
|
released / 0-owner / 2+-owner key has **no** entry. A fresh game is seeded by
|
||||||
|
`seedRegionStats` (every owned region gets a **copy** of its owner's stats — identical values,
|
||||||
|
so the derived median equals the original and fresh-game numbers are unchanged). Legacy SQLite
|
||||||
|
rows are migrated once on load by `migrateRegionStats` (`db.load`, called without `geoOf` —
|
||||||
|
weight-invariant for the identical-value seed): a row that already has `regionStats` (v0.28+)
|
||||||
|
is returned **unchanged** (never recomputed on load → no drift), a legacy row is seeded +
|
||||||
|
recomputed (0-region guard keeps its stats). `applyTurnResult` takes an optional `geoOf`
|
||||||
|
(passed through `runTurn`; the jump route wires it via `hasGeo() ? (key) => geoOf(key) :
|
||||||
|
undefined`) and maintains the table inside the **load-bearing order events → relations →
|
||||||
|
territory → stateChanges → recompute → markers → nationOps**: a release deletes the key's
|
||||||
|
entry; a (re-)claim re-inits an absent entry as a copy of the gainer's current `stats`;
|
||||||
|
nation-level `stateChanges` (the only LLM lever for now — the prompt is **unchanged** in this
|
||||||
|
PR) write to `n.stats` **and** to every region the nation owns; then every nation with ≥1
|
||||||
|
entered region gets `stats` recomputed as its regions' weighted median (a 0-region — or no-
|
||||||
|
entered-region — nation keeps its directly-maintained `stats`, which is why the existing unit
|
||||||
|
and e2e assertions pass unchanged; without the geo manifest the recompute degrades to an
|
||||||
|
unweighted median, never throwing). The stub never touches per-region stats (`LLM_STUB=1`
|
||||||
|
e2e unaffected), and the path is unit-tested (`server/engine.test.ts`,
|
||||||
|
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
|
||||||
|
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
|
||||||
|
when PR 2, the per-region LLM lever, ships).
|
||||||
|
- **Wars — `GameState.wars`, a first-class conflict entity (v0.33, #56).**
|
||||||
|
Since v0.33 the `war` relation is no longer a bare boolean flag but a named `War` entity in
|
||||||
|
`GameState.wars` (required, `[]` at creation; a legacy SQLite row without the field loads as
|
||||||
|
`[]` — additive, never wipes). Each `War` has a stable id (`w1`, `w2`, …), a `sides` list
|
||||||
|
(≥2 distinct nation ids — any two distinct members are at war with each other), `declaredTurn`,
|
||||||
|
`declaredDate`, optional `aims`, a `status` (`active` / `armistice` / `capitulation`), and an
|
||||||
|
optional `front` (region keys). `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }`
|
||||||
|
(both required, default `[]`): a **declare** creates a new active `War` (sides resolved via
|
||||||
|
`resolveNationRef` — id first, then case-insensitive unique name; a declare with <2 resolved
|
||||||
|
distinct sides is dropped); an **end** finds a war by id (unknown → no-op, never throw) and sets
|
||||||
|
its `status` to the outcome (`armistice` or `capitulation`). After declare/end,
|
||||||
|
`syncWarRelations(prev, next)` enforces the derived `war` relation: any two nations on opposite
|
||||||
|
sides of ≥1 **active** war get `war` in both directions (overriding lesser statuses — you
|
||||||
|
cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not
|
||||||
|
covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching
|
||||||
|
active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense
|
||||||
|
drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the
|
||||||
|
war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted
|
||||||
|
id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations
|
||||||
|
per turn, each narrated in `events`); `buildUserPrompt` carries the current **active** wars
|
||||||
|
(as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or
|
||||||
|
ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the
|
||||||
|
path is unit-tested only (`server/engine.test.ts`).
|
||||||
|
- **Hybrid war — `TurnResult.hybrid`, a deterministic non-kinetic operations spectrum (v0.34, #57).**
|
||||||
|
`TurnResult.hybrid = { ops: HybridOp[] }` (required; default `{ ops: [] }`) adds a covert /
|
||||||
|
hybrid-warfare spectrum alongside the kinetic `regionChanges` lever: `HybridOp =
|
||||||
|
{ type, from, target, detail?, outcome? }` with `type ∈ spy | sabotage | assassinate |
|
||||||
|
cyber | economic | information | proxy` and `outcome ∈ success | exposed` (the LLM
|
||||||
|
declares its *intended* outcome; the engine applies the deterministic effect).
|
||||||
|
`from`/`target` are refs resolved by the shared `resolveNationRef` (id first, then
|
||||||
|
case-insensitive unique name); a `target` that is not a resolvable nation is treated
|
||||||
|
as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the
|
||||||
|
acting nation itself — is dropped, never thrown). Per type: `sabotage` dips `economy`
|
||||||
|
(−3), `economic` dips `economy` (−5), `cyber` dips `military` (−3), `assassinate` dips
|
||||||
|
`stability` (−5); `information` nudges the from↔target relation one step apart (capped
|
||||||
|
at `hostile`), `proxy` likewise but capped at `rival`; `spy` is informational only.
|
||||||
|
A region-target dip lands on the single `regionStats[regionKey]` entry (hybrid ops
|
||||||
|
never move territory — `regionChanges` is the only lever that does); a nation-target
|
||||||
|
dip lands on every region the nation owns; afterwards the target's derived `stats` is
|
||||||
|
re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership
|
||||||
|
invariant is untouched. **Exposed** (`outcome: "exposed"`) is a backfire: the intended
|
||||||
|
effect is NOT applied; instead the from↔target relation moves one step *toward*
|
||||||
|
`hostile` (capped; `war` is never created or escalated by an exposed op) and a
|
||||||
|
diplomatic/failed event is auto-appended to the turn's `events`.
|
||||||
|
**Additive only — no `GameState.hybrid` field, no `db.ts` change** (the ops are
|
||||||
|
consumed inside `applyTurnResult` after territory + wars and before markers).
|
||||||
|
`isMeaningful` counts hybrid ops; the system prompt documents the field + per-type
|
||||||
|
effects (the "unclear" fallback and the stub both return `hybrid: { ops: [] }`), and the
|
||||||
|
user payload carries the player's `relations` map so the model can reason about the
|
||||||
|
relation caps. The **stub never** proposes a hybrid op, so e2e (CI, `LLM_STUB=1`) is
|
||||||
|
unaffected and the path is unit-tested only (`server/engine.test.ts`).
|
||||||
|
- **Trade & embargo economy layer — `GameState.trades` / `GameState.embargoes`, `TurnResult.economy` (v0.35, #58).**
|
||||||
|
`TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required;
|
||||||
|
default `{ trade: [], embargo: [] }`) is the model's lever for the economic layer: a
|
||||||
|
bilateral **trade** (`{ a, b, lift? }` — symmetric, stored **sorted** `a < b`) and a
|
||||||
|
unilateral **embargo** (`{ from, target, lift? }` — ordered). No `lift` declares/imposes,
|
||||||
|
`lift: true` ends/lifts (a no-op when absent). Refs are resolved at apply time by the
|
||||||
|
shared `resolveNationRef` (id first, then case-insensitive unique name); an unknown /
|
||||||
|
ambiguous / self-pairing op is dropped (never thrown). The lists live in `GameState`
|
||||||
|
(`trades`/`embargoes`, required, `[]` at creation; a legacy SQLite row without them loads
|
||||||
|
as `[]` — additive, never wiped, like `wars`/`markers`). **Deterministic, no hidden
|
||||||
|
randomness.** Every committed turn, in `applyTurnResult` (after hybrid, before markers):
|
||||||
|
(1) `applyEconomyOps` applies this turn's declares/lifts to the active lists (idempotent
|
||||||
|
declares — an already-active pair/ordered pair is not duplicated); (2)
|
||||||
|
`suspendEconomyOnWar` **auto-suspends** any trade or embargo whose two nations are on
|
||||||
|
opposite sides of an **active** `War` (#56) — a war severs the economic link, so the
|
||||||
|
suspended pair stops producing its effect (this also drops a trade declared this turn
|
||||||
|
between two nations that just went to war); (3) `applyEconomyEffects` applies the per-turn
|
||||||
|
effect to the **full** active lists: each active trade gives **both** parties a small,
|
||||||
|
deterministic `economy` gain (`TRADE_ECONOMY_BONUS`), each active embargo dips the
|
||||||
|
**target's** `economy` only (`EMBARGO_ECONOMY_PENALTY`) — clamped to 0–100, mirrored to
|
||||||
|
every region the nation owns (`regionStats`), and each affected nation's derived `stats`
|
||||||
|
re-derived afterwards (same 0-region / no-entry guards as the hybrid dip). Sole ownership
|
||||||
|
is untouched (the layer moves stats, never territory). `isMeaningful` counts non-empty
|
||||||
|
`economy.trade` / `economy.embargo`; the system prompt documents the field + constraints
|
||||||
|
(at most 1–2 ops per turn, narrated in `events`; warring pairs auto-suspended), and the
|
||||||
|
user payload carries the active `trades`/`embargoes` so the model can `lift` them. The
|
||||||
|
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
|
||||||
|
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
|
||||||
|
(`server/engine.test.ts`).
|
||||||
|
- **Resources & Industry — per-region resource profiles + the `industry` lever (v0.36, #59).**
|
||||||
|
Since v0.36 each of the 1,800 sub-regions carries a **synthesized 4-value resource profile**
|
||||||
|
(`{ oil, iron, food, industry }`, each 0–1, 2dp) from the committed `public/region-resources.json`
|
||||||
|
— built by the deterministic, idempotent `scripts/build-region-resources.mjs` (an FNV-1a hash of
|
||||||
|
`` `${key}\u0000${resource}` `` scaled by an area factor from `region-geo.json`, clamped to 1,
|
||||||
|
byte-identical re-run) and loaded by `server/regions.ts` via the same optional-manifest pattern as
|
||||||
|
the other manifests (`hasResources()` / `resourceOf()`, probe `public/` then `dist/`; a missing or
|
||||||
|
malformed file degrades — boosts use the unscaled base value and the aggregate is omitted from the
|
||||||
|
prompt, never a crash). `TurnResult.industry = { projects: IndustryProject[] }` (required, default
|
||||||
|
`{ projects: [] }`; `IndustryProject = { regionKey, type }`, `type ∈ INDUSTRY_TYPES =
|
||||||
|
oil | iron | food | industry`) declares a project on a region. `applyTurnResult` maintains
|
||||||
|
`GameState.industry` (required, `[]` at creation; a legacy SQLite row without the field loads as
|
||||||
|
`[]` — `db.ts`, additive, never wiped) as a **per-region SET**: a region key not in the valid-keys
|
||||||
|
manifest is dropped; a region that already hosts a project is untouched (the first declared type
|
||||||
|
wins — re-declaring is a no-op, the type is never replaced). Every committed turn,
|
||||||
|
`applyIndustryEffects` (placed after the economy block, before markers; the owner is resolved from
|
||||||
|
the FINAL post-claim ownership) grants each active project's region's **SOLE owner** a small,
|
||||||
|
deterministic economy boost — `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's
|
||||||
|
`industry` resource value is ≥ 0.5 (without the manifest, just the base) — clamped to 0–100,
|
||||||
|
mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats`
|
||||||
|
re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). A project on a
|
||||||
|
region with 0 or 2+ owners yields no boost but is **kept** in the list (never removed, never
|
||||||
|
thrown); sole ownership is untouched (the layer moves stats, never territory). `isMeaningful`
|
||||||
|
counts non-empty `industry.projects`; the system prompt documents the field + constraints (a region
|
||||||
|
holds at most one project — never re-declare on a region that already has one; at most 1–2 per
|
||||||
|
turn, each narrated in `events`), and the user payload carries the active projects (`industry`)
|
||||||
|
plus each nation's aggregate resource profile (`resources` — `deriveNationResources` in
|
||||||
|
`shared/protocol.ts`, the area×population-weighted **average** of the owned regions' profiles;
|
||||||
|
omitted entirely when the manifest is not loaded). The **stub never** declares a project
|
||||||
|
(`industry: { projects: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
|
||||||
|
only (`server/engine.test.ts`, `server/protocol.test.ts`, `server/regions.test.ts`,
|
||||||
|
`server/db.test.ts`, `scripts/build-region-resources.test.ts`).
|
||||||
|
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||||
|
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||||
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
|
clicked for a small dossier label); the manual "Inspect" selector and a map click both set the
|
||||||
|
same target and stay in sync (a manual pick clears the label). Ownership is resolved from the
|
||||||
|
authoritative `RegionMap` (keyed by **region key**) via the feature's stamped
|
||||||
|
`properties.regionKey`, using the shared `mapFeatureOwner()` helper (`shared/protocol.ts`) —
|
||||||
|
`WorldMap` stamps each rendered feature with its key from `region-keys.json` when it builds the
|
||||||
|
choropleth source. 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 raw sub-region `id` (not a
|
||||||
|
`RegionMap` key). A sub-region whose key has no owner leaves the current dossier target
|
||||||
|
unchanged — never a broken dossier. The hover path (a non-closeable tooltip showing the region
|
||||||
|
name + owner chip + key stats) uses the same resolution. `properties.parent` is static
|
||||||
|
geographic-origin data — never use it for ownership or display.
|
||||||
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
||||||
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
||||||
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||||||
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
|
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
|
the player is allied with the first other nation (by key order in the `nations` record), and the
|
||||||
date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub
|
date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the
|
||||||
deterministic.
|
player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload —
|
||||||
|
stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK
|
||||||
|
in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
|
||||||
|
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
|
||||||
|
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
|
||||||
|
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a)
|
||||||
|
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
|
||||||
|
still true since P4b implemented split/merge), so the confirm/reject path is covered by unit
|
||||||
|
tests only, mirroring the P3 approach; all e2e assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally
|
||||||
|
bypasses the tool loop in a single round. 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`.
|
||||||
|
|
||||||
@@ -175,6 +515,12 @@ history/
|
|||||||
> ignoring `reasoning`. `chat()` retries transient failures (timeout/abort, 5xx, network
|
> 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
|
> 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.
|
> (`[llm] chat ok` / `[llm] chat failed`). No auth header is sent — the endpoint requires no key.
|
||||||
|
> The same machinery backs `chatWithTools()` (the function-calling loop used by turns when the
|
||||||
|
> region-adjacency manifest is loaded): a `finish_reason: "tool_calls"` response is a *successful*
|
||||||
|
> round (never a truncation error), each requested tool call is run through the injected executor,
|
||||||
|
> the assistant tool-call message plus one `tool` result message per call are appended to the
|
||||||
|
> history, and the loop re-sends until a final `content` — max `DEFAULT_MAX_TOOL_ROUNDS = 6`
|
||||||
|
> tool rounds, after which one last *no-tools* request forces the answer.
|
||||||
| `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) |
|
||||||
@@ -214,5 +560,11 @@ 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/` data files
|
||||||
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).
|
(`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the
|
||||||
|
ownership/adjacency manifests — `region-centroids.json` — the marker anchors (#44) — and
|
||||||
|
`countries.geo.json`) are copied into `dist/` by Vite at
|
||||||
|
build time, so the prod image serves them from `dist/` (no separate copy). The server's region
|
||||||
|
loader (`server/regions.ts`) probes `public/` then `dist/` for the manifest pair, because the
|
||||||
|
Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently
|
||||||
|
never activated in prod).
|
||||||
|
|||||||
+384
@@ -2,6 +2,390 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.38.0 — 2026-10-15 20:06
|
||||||
|
- **#61: Named leaders — a per-nation `leader` + a `TurnResult.leaders` lever (assassination / death / appointment) with a deterministic stability effect and succession narration.** Each nation may carry a single named head of state (`Nation.leader`); the model changes it each turn via `leaders.changes`. The engine is **fully deterministic (no hidden randomness)** and **additive** (no `db.ts` change — `leader` is an optional `Nation` field; a pre-v0.38 row loads with it absent, i.e. "no leader").
|
||||||
|
- **Types (`shared/types.ts`).** `Nation.leader?: { name: string }` (optional; `undefined`/absent = the nation has no leader).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `LeaderCause` (`'assassination' | 'death' | 'appointed'`), `LeaderChange` (`{ target; name; cause }` — `target` is a nation ref, `name` is the new leader's name), and `LeaderOps` (`{ changes: LeaderChange[] }`); `TurnResult.leaders` is **required**, placed between `nuclear` and `markers`, default `{ changes: [] }`. New exported **`LEADER_STABILITY_EFFECT = { assassination: −15, death: −3, appointed: 0 }`** (the deterministic stability dip by cause). `nationForPrompt` now includes the nation's `leader` (omitted when absent), so `buildUserPrompt` carries each nation's current leader with no separate wiring.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateLeaderOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (≤120), a non-empty `name` (≤120), and a `cause` that is one of the three literals — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear ops).
|
||||||
|
- `applyLeaderChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyHybridOp` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). **GATE**: an `assassination`/`death` requires the target to already have a leader (a leaderless target is dropped); `appointed` is always valid (it creates the first leader). On a valid change it sets `n.leader = { name }` and applies `LEADER_STABILITY_EFFECT[cause]` to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry, so a 0-region nation keeps its directly-mutated value). Finally it appends a narration event: a dramatic **`military`** event for an assassination, a **`political`** succession event for a death or an appointment. The replacement never moves territory (sole ownership is untouched).
|
||||||
|
- `applyTurnResult` — the leader block runs **after `nuclear` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → **leaders** → markers → nationOps).
|
||||||
|
- `isMeaningful` — a non-empty `leaders.changes` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `leaders` (default `{ changes: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `leaders` field (the three causes, the stability effects, the leaderless-gate, the "at most 1–2 per turn, narrated" rule) and contrasts it with the lighter `hybrid.assassinate` op (a −5 dip, no leader replacement) in both tool and plain modes.
|
||||||
|
- `runTurn` — the "unclear" fallback carries `leaders: { changes: [] }`.
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `leaders: { changes: [] }` and the `StubPayload` nations type gains an optional `leader` — the stub **never** changes a leader, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
|
||||||
|
- **Tests.** New unit tests in `engine.test.ts`: `validateLeaderOps` (`{changes}` object, bare array, unknown-cause drop, missing target/name drop, malformed top-level, mixed batch keeps only valid); `isMeaningful` (a leader change is meaningful, empty leaders is not); `applyTurnResult` (assassination with a leader → replaced + −15 + `military` event; assassination / death on a leaderless nation → dropped, no stat change, no event; death → replaced + −3 + `political` succession event; appointment on a leader-having and on a leaderless nation → sets the leader, neutral (no dip); a multi-region nation → every region dipped and stats re-derived; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing). Two `db.test.ts` round-trips: a legacy row with no leader loads with it absent, and a set leader round-trips through save/load.
|
||||||
|
- **Version** bumped `0.37.0 → 0.38.0`.
|
||||||
|
|
||||||
|
## v0.37.0 — 2026-10-15 19:15
|
||||||
|
- **#60: Nuclear / WMD — a per-nation `nuclear` capability flag + a `TurnResult.nuclear` lever (`arm` + `strikes`) with deterministic strikes and MAD retaliation.** A nation is *nuclear-capable* when its `nuclear` flag is `true` **or** it currently owns a `rocket` marker (a launch site is an arsenal). The model can **arm** nations (grant the flag — idempotent; a newly-armed nation is capable the same turn, since `arm` is applied before `strikes`) and fire **strikes** (`{ from, target }`). The engine is **fully deterministic (no hidden randomness)** and **additive** (a `db.ts` change is not needed — `nuclear` is an optional `Nation` field; a pre-v0.37 row loads with the flag absent).
|
||||||
|
- **Capability (`shared/protocol.ts`).** `isNuclearCapable(nation, markers)` — exported pure helper: `nation.nuclear === true` **or** it owns a `rocket` marker (`marker.type === 'rocket' && marker.ownerNationId === nation.id`). Markers supply the *pre-turn* rocket set, so a rocket placed this turn makes the owner capable from the *next* turn.
|
||||||
|
- **Types (`shared/types.ts`).** `Nation.nuclear?: boolean` (optional; `undefined`/absent = not armed; default `false` behavior).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `NuclearStrike` (`{ from; target }` — `from` is the striking nation, `target` is a nation ref or a region key) and `NuclearOps` (`{ arm: string[]; strikes: NuclearStrike[] }`); `TurnResult.nuclear` is **required**, placed between `industry` and `markers`, default `{ arm: [], strikes: [] }`. New exported `NuclearStatKey` (`'economy' | 'military' | 'stability'`) and the two deterministic collapse tables: **`NUCLEAR_COLLAPSE = { economy: −40, military: −50, stability: −40 }**` (a full strike's effect on the struck nation / region owner) and **`NUCLEAR_RETALIATION = { economy: −20, military: −25, stability: −20 }`**` (the half-strength MAD backfire).
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateNuclearOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ arm: [], strikes: [] }`; `arm` entries are non-empty strings (≤120 chars) deduped; a strike needs a non-empty `from` **and** `target` (≤120) and is **dropped when it targets itself** (same ref, case-insensitive) — a self-strike is a no-op, never thrown.
|
||||||
|
- `applyNuclearOps(next, ops, state, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — new exported, the load-bearing nuclear block. **`arm` first** (idempotent; a resolve miss or a self-arm is a no-op), then **`strikes`** in order: resolve `from` (an unresolvable or non-capable striker is **dropped** — capability checked against the *pre-turn* marker set plus any flag granted this turn); a `target` that is a **nation** collapses it (the `NUCLEAR_COLLAPSE` deltas applied to its `stats` and every region it owns, each affected nation's derived stats re-derived, 0-region / no-entry guards as elsewhere), ends **every active war it fights** by **capitulation** (`endWarsBetween` captures the active pairs *before* ending so a war declared+ended the same turn is handled), and downgrades the now-inactive `war` relations to `hostile`; a `target` that is a **region key** releases it to no man's land (a key with 0 or 2+ owners, or one the striker itself owns, is **dropped**), deletes its `regionStats` entry, and collapses its **sole owner** the same way. **MAD**: if the struck nation (or the struck region's sole owner) is itself nuclear-capable, it retaliates against the striker for the half-strength `NUCLEAR_RETALIATION` (clamped, mirrored, re-derived) and the two nations' relations are forced to `hostile` (`forceHostile` — both directions, unless already `war`). Each strike and each retaliation appends a dramatic **`military`** event (`war` is not a valid `EventType`). Sole ownership is never broken by a strike (a region is released, not re-claimed) and the block never throws.
|
||||||
|
- `applyTurnResult` — the nuclear block runs **after `industry` and before `markers`** (order: events → relations → territory → stateChanges → recompute → wars → hybrid → economy → industry → **nuclear** → markers → nationOps); it passes the *original* (pre-turn) `state` for the capability marker set and the current `regionNameOf` for event text.
|
||||||
|
- `isMeaningful` — a non-empty `nuclear.arm` or `nuclear.strikes` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `nuclear` (default `{ arm: [], strikes: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `nuclear` field and a constraint bullet (the capability rule; `arm` is idempotent; a strike hits a nation or a region; the collapse + capitulation + MAD outcomes; at most one strike per turn, always narrated) in both tool and plain modes.
|
||||||
|
- `buildUserPrompt` — new optional `nuclear` param (`Record<string, boolean>`, the per-nation capability map); when provided, each `state.nations` entry in the payload is stamped with its `nuclear` capability; when undefined the key is omitted entirely (stub / no-capability prompt stays byte-identical). `runTurn` always supplies the map (computed from `isNuclearCapable` over `state.markers`).
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `nuclear: { arm: [], strikes: [] }` — the stub **never** arms a nation or fires a strike, so the e2e (CI, `LLM_STUB=1`) is unaffected and the nuclear path is covered by unit tests only.
|
||||||
|
- **Tests.** 29 new unit tests: `isNuclearCapable` (flag / own rocket / non-rocket / other's rocket / null owner); `validateNuclearOps` (well-formed, `arm` dedupe, missing `from`/`target`, self-target drop, malformed top-level); `applyTurnResult` nuclear (arm idempotency across turns, arm+strike same turn, region strike → release + stats deleted + owner collapse + no MAD + the single `military` event, nation strike + pre-existing war → capitulation + hostile, nation strike + same-turn declare → capitulation + hostile, MAD on a capable nation target, MAD on a capable region owner, rocket-marker-only capability, non-capable striker dropped, 2+-owner region dropped, 0-owner/unowned region dropped, self-target dropped, malformed strikes never throw); `isMeaningful` (arm / strike); `buildUserPrompt` (per-nation capability stamped when the map is given, omitted when undefined); and two `db.test.ts` round-trips (a legacy row with no flag loads with it absent; a set flag round-trips through save/load).
|
||||||
|
- **Version** bumped `0.36.0 → 0.37.0`.
|
||||||
|
|
||||||
|
## v0.36.0 — 2026-10-15 18:17
|
||||||
|
- **#59: Resources & Industry — per-region synthesized resource profiles + a per-region industry-project lever with a deterministic per-turn economy boost.** Every prior lever moved borders, dipped stats one-shot, or proposed a player-confirmed change; this version adds a **resource abundance layer** (every sub-region gets a synthesized 4-value profile) and a **development lever**: the model can build industry projects (oil facilities, iron mines, food operations, factories) in individual regions, and every committed turn each active project pays its region's sole owner a small, deterministic economy gain scaled by the region's `industry` resource value. The engine is **fully deterministic (no hidden randomness)**: the resource profiles are a pure function of the region key (FNV-1a) scaled by an area factor, and the per-turn effect is a fixed, clamped, mirrored, re-derived stat adjustment.
|
||||||
|
- **Manifest (`public/region-resources.json`, new).** 1,800 entries — region key → `{ oil, iron, food, industry }` (each 0–1, 2dp) — built by the new turf-free, deterministic, **idempotent** `scripts/build-region-resources.mjs` (`npm run build:region-resources`): per resource, `(fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000` → [0,1), times an area factor `1 + 0.2·min(1, log10(1+area)/8)` (max ≈1.2) from `region-geo.json` (missing geo / zero area → pure hash), clamped to 1, keys sorted, byte-identical re-run. Pinned by `scripts/build-region-resources.test.ts` (key-set equality vs `region-keys.json`, sorted order, per-entry shape, spot-checked values, determinism via a re-run).
|
||||||
|
- **Types (`shared/types.ts`).** New `RegionResources` (`{ oil; iron; food; industry }`), `IndustryType` (`'oil' | 'iron' | 'food' | 'industry'`) + `INDUSTRY_TYPES` const, `IndustryProject` (`{ regionKey; type }`), and the required `GameState.industry: IndustryProject[]` field (placed after `embargoes`). `[]` on a fresh game; rows saved before v0.36 load with it defaulted to `[]` (additive, never wiped).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `IndustryOps` (`{ projects: IndustryProject[] }`); `TurnResult.industry` is required, placed between `economy` and `markers`. New exported `deriveNationResources(regionKeys, resourceOf?, geoOf?)` — a nation's aggregate profile as the **area×population-weighted AVERAGE** (an average, not a median, is the meaningful aggregation for an abundance profile) of the resource profiles of its owned regions, same weight formula as `deriveNationStats`, 2dp-rounded, zero profile when no owned region has a profile.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `isIndustryType(t)` — exported guard over the four types (mirrors `isHybridOpType`).
|
||||||
|
- `validateIndustryOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ projects: [] }`; each op needs a non-empty `regionKey` (≤120 chars) and a known `type`.
|
||||||
|
- `industryBoost(regionKey, resourceOf?)` — internal; `INDUSTRY_ECONOMY_BONUS = 1` plus one extra point when the region's `industry` value is ≥ 0.5 (without the manifest, just the base).
|
||||||
|
- `applyIndustryOps(next, ops, validKeys?)` — internal; applies the turn's projects to `GameState.industry` as a **per-region SET** — a key not in the valid-keys manifest is dropped; a region that already hosts a project is untouched (first type wins; re-declare is a no-op, the type is never replaced).
|
||||||
|
- `applyIndustryEffects(next, resourceOf?, geoOf?)` — new exported; the per-turn effect, applied **every committed turn** to the **full** active list: each project grants its region's **SOLE owner** (owner resolved from the FINAL post-claim ownership; 0 or 2+ owners → no boost, project **kept**, never thrown) a deterministic economy boost, clamped to 0–100, mirrored to every region the owner holds (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). Sole ownership is untouched (the layer moves stats, never territory). No-op when there are no active projects; never throws.
|
||||||
|
- `applyTurnResult` — new `resourceOf` param (last) and **industry block** (after the economy block, before markers): `applyIndustryOps` → `applyIndustryEffects` (ops first, so a project declared this turn is paid this turn).
|
||||||
|
- `isMeaningful` — non-empty `industry.projects` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `industry` (default `{ projects: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `industry` field and a constraint bullet (a per-resource development lever; one project per region — never re-declare on a region that already has one; at most 1–2 per turn, each narrated in `events`) in both tool and plain modes.
|
||||||
|
- `buildUserPrompt` — new optional `nationResources` (each nation's aggregate profile) and `industryProjects` (the active projects) params — emitted as `resources` / `industry` in the payload, only when provided (so the stub / no-manifest prompt stays byte-identical).
|
||||||
|
- `runTurn` — new optional `resourceOf` (last); computes `nationResources` via `deriveNationResources` **only when** the resource manifest is loaded (otherwise omitted from the prompt); passes both new payload fields; the "unclear" fallback carries `industry: { projects: [] }`; `applyTurnResult` receives `resourceOf`.
|
||||||
|
- **Regions loader (`server/regions.ts`).** The optional-manifest pattern extended: `region-resources.json` is probed after `public/` then `dist/`; malformed entries (any of the four values not a finite number in [0,1]) are filtered out, a failure logs `[regions] failed to load region-resources.json, continuing without it` and the server keeps working without it; new `hasResources()` / `resourceOf(key)` accessors (mirror `hasGeo()` / `geoOf()`).
|
||||||
|
- **Server wiring (`server/index.ts`).** The jump route passes `resourceOf: hasResources() ? (key) => resourceOf(key) : undefined` into `runTurn` (like `geoOf`); fresh preset games are created with `industry: []` (dynamic games already via `shared/scenario.ts`).
|
||||||
|
- **Persistence (`server/db.ts`).** `load()` defaults `industry` to `[]` on rows saved before v0.36 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`/`trades`/`embargoes`) — legacy games are never wiped and never gain phantom projects.
|
||||||
|
- **Stub (`server/stub.ts`).** The deterministic stub returns `industry: { projects: [] }` — it **never** declares an industry project, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only. `StubPayload` gains optional `resources` / `industry` fields (ignored).
|
||||||
|
- **Unit tests.** New: `scripts/build-region-resources.test.ts` (5 tests); `server/regions.test.ts` (resources load/spot-check/unknown-key, degradation, prod `dist/` layout, incl. a new "with the manifest" case); `server/protocol.test.ts` — `deriveNationResources` (7 tests: area×population weighting, unweighted degradation, profile-skipping, zero profile, single region, weight fallback, 2dp rounding); `server/db.test.ts` — legacy-row default; `server/engine.test.ts` — `validateIndustryOps` (shape/drops), `applyTurnResult` industry ops (declare, per-region SET / re-declare no-op, invalid-key drop, persistence across turns), `applyIndustryEffects` (sole-owner boost, ≥0.5 scaling, multi-project accumulation, ownerless/2+ owner kept-but-unpaid, no-op empty), `isMeaningful`, the system-prompt contract (both modes), the user-prompt payload (carried / omitted), and the `runTurn` defensive init; all pre-existing fixtures gained the new required fields (mechanical, deliberate updates).
|
||||||
|
- **Client.** No change — the industry layer is an engine/LLM concern; its effects surface through the existing stats and event feed.
|
||||||
|
- **AGENTS.md.** New convention entry documenting the resource manifest, the per-region SET semantics, the sole-owner per-turn boost, and the stub/test coverage; the `public/` layout, the scripts pipeline line, and the Commands table all gained `region-resources` / `build:region-resources`.
|
||||||
|
|
||||||
|
## v0.35.0 — 2026-10-15 17:22
|
||||||
|
- **#58: Trade & Embargoes — a bilateral trade + embargo economy layer with a deterministic per-turn effect and war auto-suspension.** Every prior turn lever moved borders, dipped stats one-shot, or proposed a player-confirmed change. This version adds `TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required, default `{ trade: [], embargo: [] }`) so the model can declare/lift **bilateral trade agreements** and **unilateral embargoes**, which persist in `GameState` and apply a small, deterministic economic effect **every committed turn**. The engine is **fully deterministic (no hidden randomness)** and **additive** — declares add to the lists, lifts remove, and nothing else (not even a war, beyond the suspension below) mutates them.
|
||||||
|
- **Types (`shared/types.ts`).** New `TradeAgreement` (`{ a; b }` — a symmetric pair, stored sorted `a < b`) and `Embargo` (`{ from; target }` — an ordered unilateral pair) interfaces, plus the required `GameState.trades` and `GameState.embargoes` fields (placed after `wars`). Both are `[]` on a fresh game; rows saved before v0.35 load with them defaulted to `[]` (additive, never wiped).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `EconomyTradeOp` (`{ a; b; lift? }`), `EconomyEmbargoOp` (`{ from; target; lift? }`), and `EconomyOps` (`{ trade; embargo }`) types. `TurnResult.economy` is required, placed between `hybrid` and `markers`; a no-`lift` op declares/imposes, `lift: true` ends/lifts.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateEconomyOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ trade: [], embargo: [] }`; each op needs non-empty `a`/`b` (or `from`/`target`) refs (trimmed, ≤200 chars) and a boolean `lift` (default false). Raw refs are kept — resolution happens at apply time against the real state (like wars/hybrid ops).
|
||||||
|
- `applyEconomyOps(next, ops)` — internal; resolves each op's refs via `resolveNationRef` (id first, then unique case-insensitive name). An unresolvable / ambiguous / self-pairing op is dropped (never thrown). A no-`lift` trade **declares** the agreement idempotently (stored sorted, so `{a,b}` and `{b,a}` are the same pair); `lift: true` ends it (a no-op when absent). A no-`lift` embargo **imposes** the ordered from→target pair idempotently; `lift: true` lifts it.
|
||||||
|
- `suspendEconomyOnWar(next)` — internal; any active trade or embargo whose two nations are on **opposite sides of an active war** (v0.33, #56) is removed — a war severs the economic link, so the suspended agreement stops producing its per-turn effect from now on. Runs **after** the turn's war declare/end ops (so it sees the final `next.wars`, including a war declared this turn) and **before** the per-turn effect.
|
||||||
|
- `applyEconomyEffects(next, geoOf?)` — internal; the per-turn effect, applied **every committed turn** to the **full** active lists (not just this turn's declares): each active trade grants **both** parties a small, deterministic economy gain (`TRADE_ECONOMY_BONUS`); each active embargo applies a small, deterministic economy dip to the **target only** (`EMBARGO_ECONOMY_PENALTY`). Clamped to 0–100, mirrored to every region the nation owns (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region/no-entry guards as the hybrid dip). No-op when there are no active trades or embargoes; never throws.
|
||||||
|
- `applyTurnResult` — new **economy block** (after the hybrid block, before markers): `applyEconomyOps` → `suspendEconomyOnWar` → `applyEconomyEffects`, in that order (ops first, so a same-turn trade declared between two nations that just went to war is suspended; effects then run on the surviving full lists). Sole ownership is untouched (the economy layer moves stats, never territory).
|
||||||
|
- `isMeaningful` — a non-empty `economy.trade` or `economy.embargo` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `economy` (default `{ trade: [], embargo: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `economy` field and a constraint bullet (bilateral trade gains both parties; a unilateral embargo hits the target only; omit `lift` to declare/impose, `lift: true` to end/lift; at most 1–2 ops per turn, narrated in `events`; trades/embargoes between warring nations are auto-suspended) in both tool and plain modes.
|
||||||
|
- `buildUserPrompt` — new optional `trades` and `embargoes` params (the currently active agreements/embargoes) so the model knows what is active and can `lift` it; emitted only when provided.
|
||||||
|
- `runTurn` — passes `state.trades` and `state.embargoes` to `buildUserPrompt`; the "unclear" fallback carries `economy: { trade: [], embargo: [] }`.
|
||||||
|
- **Stub (`server/stub.ts`).** The deterministic stub returns `economy: { trade: [], embargo: [] }` — it **never** declares or lifts a trade or embargo, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only (`server/engine.test.ts`).
|
||||||
|
- **Persistence (`server/db.ts`).** `load()` defaults `trades`/`embargoes` to `[]` on rows saved before v0.35 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`) — legacy games are never wiped and never gain phantom trades/embargoes.
|
||||||
|
- **Game creation (`server/index.ts`, `shared/scenario.ts`).** Fresh games (preset and dynamic) are created with `trades: []` and `embargoes: []`.
|
||||||
|
|
||||||
|
## v0.34.0 — 2026-10-15 16:35
|
||||||
|
- **#57: Espionage & Hybrid War — a non-kinetic operations spectrum with a deterministic per-type effect and an exposure backfire.** Every prior turn lever was kinetic (conquest, state-change, region-change, wars, takeovers) or a player-confirmed proposal (nation ops). This version adds `TurnResult.hybrid = { ops: HybridOp[] }` (required, default `{ ops: [] }`) so the model can run **covert, non-kinetic** operations: spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory these **never move borders** — they dip a target's stats or sour a relation. The engine is **fully deterministic (no hidden randomness)**: the LLM declares an intended `outcome` (`success` by default, or `exposed`); a successful op applies its small per-type effect, an `exposed` op **backfires** (no effect, the actor's relation with the target sours one step toward hostile, and an auto `diplomatic`/`failure` event is emitted).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `HybridOpType` (the seven types) + `HYBRID_OP_TYPES` const, and the `HybridOp` interface (`{ type; from; target; detail?; outcome? }`). `from` is the acting nation (id or unique name — resolved at apply time); `target` is **either** a nation (id/name) **or** a region key (hits that region's sole owner). `TurnResult.hybrid = { ops: HybridOp[] }` is required, placed between `wars` and `markers`.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `isHybridOpType(t)` — exported guard over the seven types.
|
||||||
|
- `validateHybridOps(raw)` — new exported, defensive validator (never throws): accepts the `{ ops: […] }` object **or** a bare array; each op needs a known `type`, a non-empty `from` (≤200) and non-empty `target` (≤200); `outcome:'exposed'` is kept (success/missing is omitted to keep the shape minimal); `detail` capped at 300.
|
||||||
|
- `HYBRID_STAT_EFFECT` — exported table of the deterministic per-type stat dip: `sabotage`→economy −3, `assassinate`→stability −5, `cyber`→military −3, `economic`→economy −5; `spy`/`information`/`proxy` have no stat effect.
|
||||||
|
- `shiftRelationTowardHostile(state, a, b, cap)` — exported; nudges the bilateral relation **one** rung up the hostile ladder (`ally→neutral→rival→hostile`), symmetrically, capped at `cap`; a missing relation starts at `neutral`; a `war` relation is never touched (a covert op neither fabricates nor downgrades a war). Used for the `information`/`proxy` nudge and the `exposed` backfire.
|
||||||
|
- `resolveHybridTarget(state, target, validKeys?)` — internal; resolves a target to the concrete nation it affects: a nation ref via `resolveNationRef`, else a region key's **sole** owner (0 or 2+ owners → not targetable); `validKeys` (when supplied) gates the region path.
|
||||||
|
- `applyHybridOp(next, op, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — internal; resolves `from`/`target` (unknown/ambiguous actor, unresolvable target, and self-targets are dropped — never throw), then applies the per-type effect: an `exposed` op backfires (relation sours one step, cap hostile, auto `diplomatic`/`failure` event); a successful stat op dips the target's `regionStats` (the single named region for a region target, otherwise **every** region the target owns) and re-derives the target nation's aggregate stats (same 0-region/no-entry guards as the main recompute) so the effect persists; `information`/`proxy` nudge the relation (caps `hostile`/`rival`); `spy` is informational only.
|
||||||
|
- `applyTurnResult` — new **hybrid block** (after the wars block, before markers): applies each validated op against the post-wars state. Sole ownership is preserved (a hybrid op affects stats, never ownership).
|
||||||
|
- `isMeaningful` — a non-empty `hybrid.ops` makes a turn meaningful.
|
||||||
|
- `validateTurnResult` — initializes and validates `hybrid` (default `{ ops: [] }`).
|
||||||
|
- `buildSystemPrompt` — documents the `hybrid` field and a HARD CONSTRAINTS bullet (non-kinetic, never moves borders, the per-type effects, the `exposed` backfire, region-key targets hit the sole owner, at most a few per turn, narrate each in `events`) in both tool and plain modes.
|
||||||
|
- `buildUserPrompt` — new optional `relations` param (the player's `nationId → RelationStatus` map) so the model sees the ladder its ops nudge and the starting point before a backfire; emitted only when provided.
|
||||||
|
- `runTurn` — computes the player's `relations` from state and passes them as the last `buildUserPrompt` arg; the "unclear" fallback carries `hybrid: { ops: [] }`.
|
||||||
|
- **Additive only.** No `GameState` field, **no** DB migration, **no** `db.ts` change — `hybrid` lives entirely in the `TurnResult` and is consumed by `applyTurnResult`.
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `hybrid: { ops: [] }` — the stub **never** runs a covert op (mirrors `nationOps: []`, `takeovers: []`, `wars`). `StubPayload` gains an optional `relations` field (ignored). E2E (CI, `LLM_STUB=1`) is fully unaffected; the path is unit-tested only.
|
||||||
|
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `isHybridOpType`; `validateHybridOps` (both raw shapes, trim/cap, exposed-vs-success, drop rules); `validateTurnResult` — hybrid; `isMeaningful` — hybrid; `shiftRelationTowardHostile` (ladder, caps, war-untouched, missing=neutral); `applyTurnResult` — hybrid (each stat type's dip, nation-vs-region target, the per-type relation nudge & caps, `exposed` backfire + auto event, exposed-on-war, drop rules for unknown actor / self-target / unknown region / double-owned region, the `validKeys` gate, borders-untouched invariant, `HYBRID_STAT_EFFECT` magnitudes); `buildSystemPrompt` — hybrid contract (both modes); `buildUserPrompt` — relations; stub — hybrid.
|
||||||
|
- **Client.** No change — hybrid ops are an engine/LLM concern; their effects surface through the existing stats, relations, and event feed.
|
||||||
|
- **AGENTS.md.** New convention entry documenting the `hybrid` field, the deterministic per-type effects, the `exposed` backfire, the region-vs-nation targeting, and the stub/test coverage.
|
||||||
|
|
||||||
|
## v0.33.0 — 2026-10-15 15:45
|
||||||
|
- **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally.
|
||||||
|
- **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `WarDeclareOp` (`{ sides: string[]; aims?: string[] }`) and `WarEndOp` (`{ id: string; outcome: 'armistice'|'capitulation'; terms?: string }`); `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` (both required, default `[]`). New `WarInfo` interface (`{ id; sides; aims? }`) for the user-prompt payload.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateWarOps(raw)` — new exported, defensive validator (never throws): non-object → empty; `declare` — sides trimmed/capped 200/deduped, ≥2; aims ≤5×200; `end` — id ≤200, outcome exactly `armistice`|`capitulation`, terms ≤400.
|
||||||
|
- `activeWarPairs(wars): Set<string>` — NUL-joined sorted pair keys for active-only wars.
|
||||||
|
- `atWar(wars, a, b)` — convenience check.
|
||||||
|
- `syncWarRelations(prev, next)` — exported; enforces the derived `war` relation (active pairs → `war` both directions; ended pairs not covered by another active war → `hostile`; bare `war` never downgraded, never force-created).
|
||||||
|
- `freshWarId(taken)` — first free of `w1`, `w2`, … (next to `freshNationId`).
|
||||||
|
- `applyTurnResult` — new **wars block** (after takeovers, before markers): declare → create `War` (sides resolved via `resolveNationRef`; <2 distinct → dropped); end → find by id (unknown → no-op); then `syncWarRelations(state, next)`.
|
||||||
|
- `isMeaningful` — a non-empty `declare` or `end` makes a turn meaningful.
|
||||||
|
- `buildSystemPrompt` — documents the `wars` field (declare/end, at most 1–2 per turn, each narrated in `events`; the `war` relation is derived — do not emit it directly for active-war pairs).
|
||||||
|
- `buildUserPrompt` — carries current **active** wars as `WarInfo[]` so the model can `end` by id.
|
||||||
|
- `runTurn` — computes active-only `wars` from `state.wars`, passes as last arg; the "unclear" fallback carries `wars: { declare: [], end: [] }`.
|
||||||
|
- **DB (`server/db.ts`).** `load` defaults `state.wars` to `[]` when absent (after the markers default, before `migrateRegionStats`).
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `wars: { declare: [], end: [] }` — the stub never declares or ends a war (mirrors `nationOps: []`, `takeovers: []`). E2E (CI, `LLM_STUB=1`) is unaffected; the path is unit-tested only.
|
||||||
|
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `freshWarId`, `validateWarOps`, `validateTurnResult` (wars), `activeWarPairs` / `atWar`, `syncWarRelations`, `applyTurnResult` — wars (declare creates war + derives relations; end removes active status + un-derives relations; mutual-defense drag-in; unknown-id end no-op; duplicate declare skip; unresolvable-ref drop; bare-war back-compat; relations-override-forced-back; same-turn declare+end leaves prior relation untouched), `isMeaningful` — wars, `buildSystemPrompt` — wars contract, `buildUserPrompt` — wars, stub — wars.
|
||||||
|
- **Client.** No change — wars are an engine/LLM concern; the UI already renders the `war` relation from the `Relation` type (which the engine now keeps in sync).
|
||||||
|
- **AGENTS.md.** New convention entry documenting the `War` entity, the `syncWarRelations` algorithm, the mutual-defense drag-in, and the stub/test coverage.
|
||||||
|
|
||||||
|
## v0.32.0 — 2026-10-15 10:56
|
||||||
|
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
|
||||||
|
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
|
||||||
|
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
|
||||||
|
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
|
||||||
|
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
|
||||||
|
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
|
||||||
|
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
|
||||||
|
|
||||||
|
## v0.31.0 — 2026-10-15 10:22
|
||||||
|
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
||||||
|
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
||||||
|
- **Shared contract (`shared/scenario.ts`, dependency-free).** `EraTemplate` / `TemplateMeta` / `SuggestedPower`; `WorldDraft = { nations: WorldNation[]; events? }` where `WorldNation.territory` holds **ISO_A3 country codes** (not region keys) and `relations` are numeric sentiments (−100..100); `Checkpoint = string`; the four message shapes (`ScenarioDraftRequest/Response`, `ScenarioCommitRequest/Response`). Pure transforms: `validateWorldDraft(raw, { validCountryCodes })` — defensive parse/clamp/normalize that never throws (name-required, id kept-or-slug fallback, duplicate ids suffixed, territory upper-cased/deduped and filtered to known ISO_A3 codes when a valid set is given, relations re-keyed to assigned ids via id or case-insensitive name match with self/unknown refs dropped, optional backstory events kept only when they name a known nation; returns `null` when the result is unusable — no nations, or no valid territory to render); `relationFromScore` (≥60 ally · 0..59 neutral · −40..−1 rival · −80..−41 hostile · ≤−81 war); `worldDraftToGameState(draft, expand, playerNationId, opts)` — expands ISO_A3 territory to region keys via the injected `expand` (`expandCountriesToKeys` in production), seeds `regionStats` (so each owned region starts with its owner's stats and the derived aggregate equals them on turn 1), a clean turn-1 state (`version:1`, `turn:1`, empty `unowned`/`markers`/`pendingNationOps`/`pending`), `presetId: 'dynamic'`, and a guard that re-points a dangling `playerNationId` to the first nation so the game is always playable.
|
||||||
|
- **Scenario LLM steps (`server/scenario.ts`).** Two plain `chat()` calls (no tools, no separate advisor endpoint — the advisor is embodied in step 1's checkpoint list): `generateDraft({ template, description, year })` → `{ world, checkpoints }` (the prompt carries the era description, suggested powers, tensions, the user's description, the year, and the valid ISO_A3 reference), and `finalizeWorld({ …, approvedCheckpoints, draft, playerNationId })` → final `GameState` (re-asked to produce the final world constrained to the approved checkpoints, based on the step-1 draft the client re-sends so the chosen nation survives; falls back to the draft itself when the model returns nothing usable). Same defensive parsing as the engine (strip fences → first balanced object → `JSON.parse` → `validateWorldDraft`), one nudge-retry when the answer is unusable; a transport failure (model down) throws → HTTP 502, a bad *answer* degrades to the fallback and never crashes.
|
||||||
|
- **Scenario stub (`server/stubScenario.ts`).** Deterministic transport for the two scenario calls only (discriminated by `SCENARIO_GENERATE_MARKER` / `SCENARIO_REGENERATE_MARKER` in the system message, mirroring how the turn stub keys off `ADVISOR_MARKER`): a fixed 3-nation world (Alba/FRA 70·60·65·55, Boreas/RUS 55·70·50·60, Gamma/ESP 30·45·70·40) + one backstory event ("The new order takes shape") + a fixed 3-checkpoint list; the finalize step refines the world deterministically from the approved checkpoints (each naming a nation by its 4-letter name prefix raises that nation's stability +5, clamped) — so "finalize respects the approved checkpoints" is observable end-to-end. The turn stub stays 100% unchanged.
|
||||||
|
- **Routes (`server/index.ts`).** `GET /api/scenarios/templates` (the 3 manifest entries), `POST /api/scenarios/draft` (`{ templateId, description, year? }` → `{ world, checkpoints }`), `POST /api/scenarios/commit` (`{ templateId, description, year?, draft?, approvedCheckpoints, playerNationId }` → `{ id }` — the finalized world is saved to SQLite with `startDate = <year>-01-01` and title = the template name). A separate `scenarioLlm` client (the scenario stub under `LLM_STUB=1`, the same live `qwen3.8-27b` client otherwise) — the turn `llm` is untouched. `POST /api/games/:id/jump` synthesizes a minimal `Preset` (`dynamicPresetFromGame`) for a saved dynamic game (`presetId: 'dynamic'` has no static file on disk — `runTurn` only consumes title/startDate/worldBackground/rules) so a created scenario plays with the normal turn engine; a genuinely-missing static preset still 404s.
|
||||||
|
- **Client.** New `ScenarioForm` (3 template cards, a pre-filled editable year, a description textarea, Generate → `/draft`; a 502 toasts and stays on the form) and `CheckpointReview` (the draft world as nation cards with stats, the checkpoint checklist with per-item approve/remove, a pick-a-nation-to-lead step, Finalize → `/commit` → navigates to `#/game/<id>`; a 502 toasts and stays on review). `PresetPicker` gains a "Create scenario" card, and `App.tsx` wires the `home → form → review → navigate` flow (a distinct `scenarioBusy` flag that never gates "Jump forward" or the turn flow).
|
||||||
|
- **Tests.** Unit: the template store (manifest + all 3 files' shape); `validateWorldDraft` / `relationFromScore` / `worldDraftToGameState` (valid → clean, stat/score clamping, bad country codes dropped, garbage → null without throwing, id dedup, territory expansion, `regionStats` seeding, player fallback, turn-1 invariants); `generateDraft` / `finalizeWorld` against the scenario stub (deterministic output, finalize respecting the approved checkpoints, a saved playable game). E2E: a new `e2e/scenario.spec.ts` (2 tests — the templates manifest over the API, and the full deterministic flow under `LLM_STUB=1`: near-future (2035) + description → draft → remove one checkpoint → lead Boreas → finalize (checkpoints shift stats) → a playable game at the `#/game/<id>` deep link with turn 1, correct date, dossier stats, map legend, and the backstory event; plus an empty jump proving the dynamic game runs the normal turn loop). The existing `e2e/sim.spec.ts` is unchanged. Totals: **307 unit tests (14 files)**, **16 e2e tests (2 scenario + 14 sim)**; typecheck, lint, and build green.
|
||||||
|
|
||||||
|
## v0.30.0 — 2026-10-15 08:22
|
||||||
|
- **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there).
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender).
|
||||||
|
- **Types (`shared/types.ts`).** `EventOutcome` gains `'repelled'` (engine-only; the LLM never produces it).
|
||||||
|
- **Colors (`src/lib/colors.ts`).** `OUTCOME_COLORS.repelled = '#9f1239'` — a muted rose-800, distinct from `failure` (`#ef4444`) and `neutral` (`#94a3b8`).
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `applyTurnResult` gains an optional `regionNameOf` (5th optional param, after `geoOf`) and the **military hard gate** inside the snapshot-first claims loop: for each contested key (a gainer's `gain` whose current sole owner is a *different* nation), it compares the gainer's start-of-turn military to the owner's via `canConquer`; on a fail it keeps the key with the owner, leaves both nations' military untouched, and pushes one `repelled` military event (`"<Attacker>'s invasion of <region name> was repelled by <Defender>."`, using the region's geo display name when known). Re-claims (`owner === undefined`) bypass the gate.
|
||||||
|
- `buildSystemPrompt` gains a **"HOW THE FOUR 0–100 STATS DRIVE OUTCOMES"** section: the enforced military gate (incl. the no-man's-land carve-out), the soft stability takeover, and the economy growth/decay lever — present in both tool and plain modes.
|
||||||
|
- `buildUserPrompt` computes and emits two new, bounded payload fields (each only when non-empty, the rest of the payload untouched): `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`, decided by `canConquer`) and `takeoverRisks` (one line per nation with stability < 30 *and* a `gathering` marker: `Name (stability N, a gathering is in progress)`).
|
||||||
|
- `runTurn` threads `regionNameOf` through to `applyTurnResult`.
|
||||||
|
- **Server route (`server/index.ts`).** The `/jump` route wires `regionNameOf: (k) => regionName(k)` so repelled invasions name the region properly.
|
||||||
|
- **Stub (`server/stub.ts`).** Unchanged (100%) — it never releases, never proposes a nation op, and its one-conquest behavior is what e2e relies on; the new gate / takeover / economy paths are covered by unit tests only.
|
||||||
|
- **Unit tests.** `server/protocol.test.ts`: `canConquer` edges (equal, within margin, over margin, gap == margin, gap == margin + 1, margin = 0). `server/engine.test.ts`: the hard gate (proceed within margin / proceed when stronger / **repel when over margin** with a correct `repelled` event + region display name + attacker untouched / **re-claim ungated** / boundary at exactly the margin and one past it); the system prompt documents all three mechanics in both modes; the user prompt emits `frontierMilitary` (could-succeed vs repelled per neighbour) and `takeoverRisks` (and omits both fields when no frontier / no markers).
|
||||||
|
|
||||||
|
## v0.29.0 — 2026-10-15 01:05
|
||||||
|
- **#46 (PR 2 of 2): per-region stat lever — the LLM can now target individual regions' stats, and the map hover shows them.** The LLM gains a fine-grained `TurnResult.regionChanges` field (alongside the existing nation-wide `stateChanges`): each entry targets **one** region's own stats by its region key (preferred) or case-insensitive name. A `set` replaces the value; an `add` increments it (both clamped 0–100 at apply time). Region-level changes are applied **after** nation-wide `stateChanges` (so they override the nation-level value for that one region) and **before** the weighted-median recompute, which propagates every region's final value into its owning nation's derived `stats`. A region with no stats entry (unowned / no man's land) is a no-op. The map hover tooltip now shows each region's own four stats (military / economy / stability / population) alongside the region name and owner chip; the `NationPanel` keeps showing the nation-level derived aggregate. The stub stays fully deterministic (`regionChanges: []`) — e2e assertions are untouched.
|
||||||
|
- **Protocol (`shared/protocol.ts`).** New `RegionStatChange = { region: string; stat: StatKey; op: 'set'|'add'; value: number }`. `TurnResult.regionChanges: RegionStatChange[]` is **required** (default `[]`); sits right after `stateChanges`.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `resolveRegionKey(ref, validKeys, nameOf)` — pure resolver: exact key first; then a case-insensitive unique-name match; ambiguous / unknown / empty → `null`.
|
||||||
|
- `validateRegionChanges(raw, regionKeyResolver?)` — validates and resolves each entry; drops unknown / ambiguous refs (never throws); clamps `set` values at validation; `add` values are clamped at apply time.
|
||||||
|
- `parseTurnResult` / `validateTurnResult` / `runTurn` all accept an **optional** `regionKeyResolver` (2nd arg / opts) threaded to `validateRegionChanges`; the generic "unclear" fallback carries `regionChanges: []`.
|
||||||
|
- `applyTurnResult` applies `regionChanges` after `stateChanges` and before the weighted-median recompute (the load-bearing order is: events → relations → territory → stateChanges → **regionChanges** → recompute → markers → nationOps).
|
||||||
|
- `isMeaningful` — a turn with only `regionChanges` is meaningful (survives the retry/nudge path).
|
||||||
|
- `buildSystemPrompt` — documents the `regionChanges` JSON field and a constraint bullet (target one region, use keys from the "regions" table, "playerRegionStats" lists the player's current per-region stats).
|
||||||
|
- `buildUserPrompt` — computes and emits `playerRegionStats` (the player's owned regions' current stats; bounded to the player's territory to keep the prompt small; `{}` when none).
|
||||||
|
- **Server route (`server/index.ts`).** The `/jump` route wires the `regionKeyResolver` (always on; degrades to key-only matching when the adjacency manifest is absent).
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `regionChanges: []` — the per-region lever is covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
- **Client (`src/components/WorldMap.tsx`).** The hover tooltip gains a `regionStats` prop and shows the region's own four stats (`Mil / Eco / Stab / Pop`); a region with no entry shows a `–` placeholder. The name and owner chip are kept; marker rendering and the click→dossier path are unchanged. `src/App.tsx` passes `game.regionStats ?? {}`.
|
||||||
|
- **Unit tests (`server/engine.test.ts`).** New suites: `resolveRegionKey` (key match, case-insensitive name match, unknown / ambiguous → null, key-over-name priority); `validateRegionChanges` (key / name resolution, unknown / invalid stat / non-numeric / invalid op dropped, `set` clamped, `add` not clamped at validation, non-array → [], no-resolver path); `applyTurnResult` with `regionChanges` (set changes only that region, add clamps 0/100, unowned no-op, applied after stateChanges, non-mutation); prompt contract (system prompt documents the field + constraint, user prompt emits bounded `playerRegionStats`); `isMeaningful` with `regionChanges`; stub returns `regionChanges: []`.
|
||||||
|
|
||||||
|
## v0.28.0 — 2026-10-15 00:12
|
||||||
|
- **#46 (PR 1 of 2): per-region stats — a nation's four 0–100 stats become a *derived* aggregate.** Instead of storing `Nation.stats` as four independent per-nation numbers, they are now **derived** as the **area×population-weighted median** of the stats of the regions the nation owns. Each sub-region carries its own `RegionStats` in a new `GameState.regionStats: Record<regionKey, RegionStats>` table; a nation's displayed stats are the weighted median over its owned regions. This is the data foundation for the per-region LLM levers that land in **PR 2** — in this PR the LLM still only emits nation-level `stateChanges`, which are re-interpreted as applying to *all* of a nation's owned regions. A new `public/region-geo.json` manifest (area km² + population per region key) supplies the weighting. **Not deployed in this PR** — the production site updates when PR 2 ships (the change is additive and backward-compatible, so deferring the deploy is safe).
|
||||||
|
- **Data build (`scripts/`, turf-`.mjs` only; never imported by `.ts`).**
|
||||||
|
- `scripts/extract-admin0-pop.mjs` (`npm run extract:admin0-pop`) — **one-time** distillation of the 13 MB Natural Earth 1:10m admin-0 GeoJSON (a *source* file we do **not** commit) into the small committed `public/ne-admin0-pop.json` = `{ ADM0_A3 → max POP_EST }` (249 country codes, total 7,677,059,722). The 1:10m **admin-1** file the region generator uses carries no population, so the source is the **admin-0** `POP_EST` (a deliberate deviation, recorded here).
|
||||||
|
- `scripts/build-region-geo.mjs` (`npm run build:region-geo`) — builds `public/region-geo.json` = `{ regionKey → { area, population } }`: `area` = `@turf/area(finalFeature)/1e6` km² (2 dp); `population` = the parent country's `POP_EST` spread across its sub-regions **proportional to area via largest-remainder**, so every country's sub-regions sum **exactly** to its total (no rounding drift). Reads the committed `ne-admin0-pop.json` → fully reproducible in CI. **Deterministic + idempotent** (sorted keys; byte-identical re-run, pinned by the test). 1,800 entries; **total population 7,654,092,021** (the sum over the 177 repo countries).
|
||||||
|
- **Types (`shared/types.ts`, no runtime deps).** New `RegionStats = { military, economy, stability, population }` (0–100). `GameState.regionStats: Record<string, RegionStats>` is **required** (`{}` at game creation; a released / 0-owner / 2+owner region has **no** entry; a legacy SQLite row without the field is migrated on load, never wiped).
|
||||||
|
- **Pure helpers (`shared/protocol.ts`, no deps).**
|
||||||
|
- `weightedMedian(values, weights)` — sorts by value and returns the value at the first point the cumulative weight reaches half the total; non-finite/non-positive weights are excluded; empty / zero-total → 0. **Translation-invariant** (the median of identical values is that value — the invariant seeding and migration rely on).
|
||||||
|
- `deriveNationStats(regionKeys, regionStats, geoOf?)` — each stat is the weighted median over the nation's owned region keys; a region's weight is `(area>0?area:1)×(population>0?population:1)` (both fall back to 1 when the key has no geo entry, so missing geo degrades to a uniform median); a key with no `regionStats` entry is skipped; returns zero stats when no owned region has an entry (the caller's 0-region guard keeps `n.stats` in that case).
|
||||||
|
- `seedRegionStats(nations)` — seeds the table from nations: every owned key gets a **copy** of its owner's stats (first owner wins on a corrupt double-ownership), so a fresh nation's regions start identical (derived = seeded).
|
||||||
|
- `migrateRegionStats(state, geoOf?)` — **one-time** legacy migration: a row that already has `regionStats` is returned **unchanged** (no recompute on load → no drift); a legacy row (field absent) is seeded per region and each nation's stats recomputed (0-region guard). Never throws.
|
||||||
|
- **Server (`server/regions.ts`, turf-free).** Loads `region-geo.json` (probes `public/` then `dist/`) **optionally** — a missing/broken file degrades weighting to uniform (every region weight 1) while everything else keeps working. New `hasGeo()` and `geoOf(key)` accessors mirror the existing `hasCentroids`/`centroidOf`.
|
||||||
|
- **Server (`server/engine.ts`).** `applyTurnResult` gains an optional `geoOf` param and is reordered to: events → relations → **territory** → **stateChanges** → **recompute** → markers → nationOps.
|
||||||
|
- **Territory:** a **transferred** region **keeps its own** per-region stats (the conquest is what makes a nation's regions diverge); a **released** region's entry is **deleted** (no man's land carries none); a **re-claimed** region with no entry is **re-inited** to the gainer's current stats.
|
||||||
|
- **StateChanges (re-interpreted):** a nation-level `set`/`add` applies to `n.stats` **and** to every region the nation owns (clamped 0–100).
|
||||||
|
- **Recompute:** every nation's `stats` is re-derived via `deriveNationStats` (weighted by area×population when `geoOf` is present). Two guards: a **0-region** nation keeps its directly-maintained stats, and a nation **none of whose regions has an entry** keeps its stats too (avoids clobbering with zeros).
|
||||||
|
- `runTurn` threads the optional `geoOf` through to `applyTurnResult`.
|
||||||
|
- **Server (`server/index.ts`).** Game creation seeds `regionStats` via `seedRegionStats` **after** `expandCountriesToKeys` (so regions hold real keys) — a new game's derived stats equal its nation-level stats on turn 1. The `jump` route passes `geoOf: hasGeo() ? geoOf : undefined`.
|
||||||
|
- **Server (`server/db.ts`).** `load()` migrates a legacy row (regionStats absent) via `migrateRegionStats(state)` **without** `geoOf` (uniform weights — weight-invariant for the equal-value seeding, and avoids a `db`→`regions` import). v0.28+ rows load unchanged.
|
||||||
|
- **Prompt / client / stub unchanged.** `nationForPrompt`/`buildUserPrompt` still surface the (now derived) four stats; there is no per-region LLM lever yet (PR 2). The deterministic stub is unaffected (nation-level `stateChanges` flow through the re-interpretation; the median of identical values is that value, so e2e stat assertions hold).
|
||||||
|
- **Tests (189 → 225).** New `server/protocol.test.ts` (weightedMedian incl. translation-invariance / identical / zero / empty; deriveNationStats weighted vs uniform / skip-missing / zero / single / fallback-weight; seedRegionStats; migrateRegionStats v0.28+ no-op vs legacy / 0-region); `server/engine.test.ts` (transfer keeps region stats + aggregate blends, release deletes entry, re-claim re-inits, 0-region keeps stats, geoOf-weighted vs uniform recompute, nation-level change re-applies to regions, original not mutated); `server/db.test.ts` (legacy row → seeded + preserved stats; v0.28+ row loads unchanged); `server/regions.test.ts` (`hasGeo`/`geoOf` in dev + prod-`dist/` layouts, graceful degradation when absent); new `scripts/region-geo.test.ts` (1,800-entry key-set match, sorted, finite / ≤2dp area / integer population, total 7,654,092,021, per-country totals preserved vs admin-0, spot-checked values, byte-identical re-run).
|
||||||
|
- **Verification.** Typecheck, lint, **225 unit tests**, and the production build all pass (the build copies `region-geo.json` + `ne-admin0-pop.json` into `dist/` like the other manifests, so the prod container layout is covered). e2e is verified by CI on the PR (no local run).
|
||||||
|
- **Deploy:** **none** in this PR — the production site (`https://history.dev.kkhost.pl`) updates when **PR 2** ships. The change is additive + backward-compatible, so existing games migrate on load and no data wipe is needed.
|
||||||
|
|
||||||
|
## v0.27.0 — 2026-10-14 22:34
|
||||||
|
- **#44: the LLM can place named point markers on the map.** The model may now place up to a handful of named *point markers* per turn (a capital, city, factory complex, army deployment, port, fleet concentration, rocket launch site, or a political **gathering**), each anchored to a region key, persisted per game, and rendered on the choropleth as a MapLibre symbol layer.
|
||||||
|
- **Types (`shared/types.ts` + `shared/protocol.ts`).** Eight `MarkerType`s — `city, factory, army, port, capital, fleet, rocket, gathering` — with a shared `MARKER_TYPE_INFO` map (display label + color + glyph). A `MapMarker = { id, type, name, regionKey, lat, lon, ownerNationId, placedTurn }`; `GameState.markers: MapMarker[]` is **required** (`[]` at game creation, and a legacy SQLite row without the field loads as `[]` — additive, never wipes existing markers). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, default `[]`); `MarkerSpec = { type, name, regionKey }`.
|
||||||
|
- **Stable ids (`shared/protocol.ts`).** A marker's `id` is the FNV-1a 32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase base-26 characters (mirroring the region-key scheme). This makes a **re-add idempotent** (the same type+name+region never creates a second marker) and a **remove unambiguous** (by id).
|
||||||
|
- **Centroid data (`public/region-centroids.json`, new).** ~1,800 entries keyed identically to `region-keys.json`, each `{ lat, lon, spread }`: the anchor is the **centroid of the region's largest-area part** (a single representative point, not an average across disjoint parts), and `spread` ≈ **0.1 × that part's smallest bbox dimension** (in degrees). A new idempotent pipeline stage `scripts/build-region-centroids.mjs` (`npm run build:region-centroids`) regenerates it deterministically (byte-identical re-run, sha256-pinned by the test). The engine uses the centroid as the marker's base position and the `spread` to jitter multiple markers of one region around it (a deterministic per-axis FNV-1a-derived offset, so markers scatter instead of stacking).
|
||||||
|
- **Engine (`server/engine.ts`).** `applyTurnResult` processes markers **after** territory claims, in three steps: (1) remove by id (a listed-but-absent id is a no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim ownership** — a captured region follows its new owner, a region released to no man's land (or a key with 0 or 2+ owners) becomes `null` (a marker always follows its region); (3) add — each new spec is placed at its region's centroid (injected via a new optional `centroidOf` param) plus the per-axis jitter, owned by the region's post-turn owner, stamped with the acting turn. Adds are dropped (never thrown) when their region key is unknown / has no centroid, when the id already exists (idempotent re-add), or beyond the hard cap `MAX_MARKERS = 400` (later adds dropped). The system prompt documents the field, the eight types, and the placement rules; `buildUserPrompt` carries the current markers (as `MarkerInfo[]`) so the model can reference their stable ids in `remove`; `isMeaningful` treats a marker add/remove as a real world change.
|
||||||
|
- **Client (`src/components/WorldMap.tsx`).** A second GeoJSON source + a symbol layer sits above the choropleth. **Deliberate deviation from the original "name as a text label" design:** every external glyph endpoint is a dead 404 (OpenFreeMap's `fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`), and the OFM dark basemap only renders at all because it is **raster** (labels baked into the PNG tiles) — there is no working vector font. So markers are drawn as **self-contained, offline, canvas-generated per-type icons** (`drawMarkerIcon`: a distinct shape per type — circle / rounded-square / triangle / diamond / star / center-dotted circles / up-arrow — filled with the type color, outlined for legibility on the dark basemap), registered as named `ImageData` images that survive `styledata`. The marker **name is shown in the hover tooltip** (region name + owner chip + marker name) instead of a persistent on-map label, and **clicking a marker focuses its owning nation's dossier** (a marker's owner is its region's owner). There are no separate marker event handlers: MapLibre's per-layer event delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead `queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
|
||||||
|
- **Stub (`server/stub.ts`) stays marker-free** — it returns `markers: { add: [], remove: [] }` — so the e2e suite (CI, `LLM_STUB=1`) is unaffected and fully deterministic; the full marker path is covered by unit tests only.
|
||||||
|
- **Tests (168 → 189).** New `scripts/region-centroids.test.ts` (1,800 entries, key-set match, finite/world-bounds, each centroid inside its feature's bbox via turf-free coordinate iteration, spot-checked anchors, byte-identical re-run determinism); `server/regions.test.ts` (`hasCentroids`/`centroidOf` in dev + prod-`dist/` layouts, graceful degradation when absent); `server/db.test.ts` (legacy row → `markers: []`); and an `engine.test.ts` marker suite (valid add → id/position/owner/placedTurn, idempotent re-add, region-validity/centroid drops, the 400 cap, remove by id / no-op, owner-follows-region incl. release→null and re-claim, `buildUserPrompt`/system-prompt contract, `validateMarkerOps` raw cleaner, and the `runTurn` centroid wiring).
|
||||||
|
- **Verification.** Typecheck, lint, **189 unit tests**, and the production build all pass (the build copies `region-centroids.json` into `dist/` like the other manifests, so it works in the prod container layout).
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host — **no `down -v`** (markers are additive; legacy games load with `markers: []`).
|
||||||
|
|
||||||
|
## v0.26.0 — 2026-10-14 19:24
|
||||||
|
- **#41: the `RU-ARK` mainland's Arctic-coast band is re-coarsened into two new regions** — a pure re-partition of the existing polygon parts: no new geometry, no coordinate change (the feature union stays byte-identical). After #31 the mainland `RU-ARK` was a 33-part MultiPolygon; its 28-part band (parts whose centroid lat ≥ 68 — the Arctic-coast strip) is now split into:
|
||||||
|
- `RU-PEO` — Pechora (13 parts, ~37,678 km²) → new region key `pechora-qwhs`. The southwestern band (around the Pechora delta / Nenets border).
|
||||||
|
- `RU-YAM` — Yamal (15 parts, ~51,722 km²) → new region key `yamal-gosw`. The northeastern band (Yamal peninsula).
|
||||||
|
- The 5 remaining lat < 68 parts (the ~305,229 km² inland core) **stay `RU-ARK` and keep its id, name, and region key `arkhangel-sk-lhbk`** — ownership-safe, mirroring the #31 precedent. Splitting that core further requires geometry clipping (a turf-op), not a pure re-partition, and is left as a follow-up issue.
|
||||||
|
- **New pipeline stage** `scripts/split-arkhangel-band.mjs` (`npm run split:regions:band`), run between `split:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It asserts the mainland is a 33-part MultiPolygon, selects the band as the 28 parts with centroid lat ≥ 68 (exactly 28, or it fails loudly), picks the two largest-area band parts as Voronoi anchors (the smaller-longitude one, `idx 1`, anchors the Pechora side), and assigns each band part to the nearer anchor by km-weighted centroid distance `hypot(Δlon·111.32·cos(midLat), Δlat·110.574)` (ties go west). The resulting partition is exactly PEO={1,4,5,9,11,12,13,14,16,17,18,20,22}, YAM={2,6,15,19,21,23,24,25,26,27,28,29,30,31,32}, core={0,3,7,8,10}. The two new features are inserted **immediately after `RU-ARK-SZ`** (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-PEO` | `RU-YAM` | `RU-AST` | …).
|
||||||
|
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,800 entries each** (was 1,798); adjacency pairs 4,471 → 4,473. The new pairs are `pechora-qwhs ↔ yamal-gosw`, `pechora-qwhs ↔ nenets-qyga`, and `yamal-gosw ↔ severnaya-zemlya-awqp`, while the mainland `arkhangel-sk-lhbk` **loses** its `severnaya-zemlya-awqp` pair (the islets now touch the Yamal band, not the inland core) and keeps its six real neighbours (`karelia-ohem`, `kirov-gfvm`, `komi-eoeh`, `murmansk-etas`, `nenets-qyga`, `vologda-ynvr`). `new-earth-eikt` stays isolated (empty adjacency).
|
||||||
|
- **Fixed-point dataset unchanged except feature count** (1,798 → 1,800): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after — only the feature grouping changes. The RU-ARK family total stays 78 parts (mainland 5 / NE 42 / SZ 3 / PEO 13 / YAM 15).
|
||||||
|
- **Tests (168, count unchanged — existing cases updated, none added).** `scripts/regions.test.ts` (dataset is now 1,800 features; the Arkhangel feature-pin test now covers all five family members), `scripts/region-keys.test.ts` (1,800 keys; pins the unchanged `arkhangel-sk-lhbk` plus the two new keys), `scripts/region-adjacency.test.ts` (1,800 entries; pins the new PEO/YAM/SZ adjacency and the mainland's loss of the SZ pair), `server/regions.test.ts` (1,800; the RUS country expansion is now **84** sub-regions including all five keys — was 82 after #31). No e2e assertion references the region counts, so the CI e2e suite is unaffected.
|
||||||
|
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra band regions (82 → 84 sub-regions); the LLM's reference table and the player's frontier now include `pechora-qwhs` and `yamal-gosw`.
|
||||||
|
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
|
||||||
|
|
||||||
|
## v0.25.0 — 2026-10-14 17:53
|
||||||
|
- **#31: the `RU-ARK` mega-region is split into a mainland region plus two island groups** — a pure re-grouping of the existing polygon parts: no new geometry, no coordinate changes. The old single feature (Arkhangel'sk Oblast + the Arctic archipelagos, 78 parts, ~410,187 km²) becomes three features inserted immediately after `RU-ARK` (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-AST`):
|
||||||
|
- `RU-ARK` — the mainland (33 parts, ~393,256 km²); it **keeps its existing id and region key `arkhangel-sk-lhbk`**, so existing `Nation.territory` lists and SQLite ownership are untouched (ownership-safe).
|
||||||
|
- `RU-ARK-NE` — New Earth / Franz Josef Land (42 parts, ~15,721 km²) → new region key `new-earth-eikt`. Genuinely isolated (≥100 km from any land), so its adjacency list is **empty**.
|
||||||
|
- `RU-ARK-SZ` — Severnaya Zemlya (3 parts, ~1,210 km²) → new region key `severnaya-zemlya-awqp`. Its islet rings share boundary vertices with the coarsened mainland ring (a contact that pre-existed inside the single pre-split feature — no coordinate change), so it records **one** adjacency pair (SZ ↔ `arkhangel-sk-lhbk`). This deviates from the original "both islands isolated" expectation and is recorded as-is by the manifest's "shares at least one point" definition.
|
||||||
|
- **New pipeline stage** `scripts/split-arkhangel.mjs` (`npm run split:regions`), run between `fill:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It classifies each part by centroid (turf — allowed in `.mjs` only): main = lat < 76 (plus any 76–79.5° band parts west of 55°E — none exist), Severnaya Zemlya = 76 ≤ lat < 79.5 AND lon ≥ 55, New Earth = lat ≥ 79.5. It fails loudly on a mis-split, a missing feature, or a non-MultiPolygon geometry.
|
||||||
|
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,798 entries each** (was 1,796); adjacency pairs 4,470 → 4,471 (the one new pair is the SZ ↔ mainland contact; the mainland keeps its 6 real neighbours).
|
||||||
|
- **Fixed-point dataset unchanged except feature count** (1,796 → 1,798): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after the split.
|
||||||
|
- **Tests (164 → 168).** `scripts/regions.test.ts` (dataset is now 1,798 features; a new test pins the three Arkhangel features and their part counts), `scripts/region-keys.test.ts` (1,798 keys; a new test pins the unchanged main key plus the two new keys), `scripts/region-adjacency.test.ts` (1,798 entries; a new island-adjacency test: New Earth empty, Severnaya Zemlya = [mainland], mainland's neighbour list extended by exactly the SZ pair), `server/regions.test.ts` (1,798; the RUS country expansion is now **82** sub-regions including all three keys — was 80). No e2e assertion references the old counts, so the CI e2e suite is unaffected.
|
||||||
|
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra island regions (80 → 82 sub-regions).
|
||||||
|
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
|
||||||
|
|
||||||
|
## v0.24.0 — 2026-10-14 13:41
|
||||||
|
- **Part 4b of #16: nation splits and merges — "LLM proposes, player confirms" is now complete.** P4a let the LLM propose a *rename* (a safe, single-field change). Now the same confirm/reject flow actually applies the two destructive ops: a **split** (one nation dissolves into several) and a **merge** (several nations fold into one). This is the last piece of #16 — all three `NationOp` kinds are now applied on player confirm.
|
||||||
|
- **Split (`server/engine.ts`).** `applyNationOp` now handles `kind: "split"`: it resolves the parent `nationRef` and requires **≥2** named parts (fewer → a safe no-op, the op is still resolved and removed). It partitions the parent's **region-key** territory across the parts **in order** — each part gets the keys of its `territory` that the parent actually owns and that no earlier part claimed (the **first part to name a key wins**, so a key is never held twice); any of the parent's keys no part claimed go to **part 0** (in the parent's territory order), so nothing is orphaned. Each new nation gets a **fresh id** (`freshNationId`: first free of `n1`, `n2`, …, tracked in a running `taken` set so ids never collide within the op), the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 profile — *not* a copy of the parent's, since a small rump state is not the parent's power), a copy of the parent's relations, and the parent's `type`; the parent is then removed and its id dropped from every other nation's relations map (`dropRelationsTo`). If the **parent was the player's nation**, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped into range, default 0) — that same part also receives an `edits.name` override.
|
||||||
|
- **Merge (`server/engine.ts`).** `kind: "merge"`: every input ref and the survivor ref must resolve, at least **two distinct** inputs are required, and the **survivor must be one of the inputs** — any failure is a safe no-op. The survivor keeps its **id** and its **own stats**; it gains the **union of all inputs' territories** (the survivor's keys first, then each other input's unclaimed keys in its own order) and a **union of the inputs' relations** (the survivor's own view of a third party wins, else the first input's); it is renamed to `edits.name` → `into` → its own name (in that precedence). The other inputs are removed and their ids dropped from every remaining nation's relations maps; if the **player led a removed input**, `playerNationId` moves to the survivor.
|
||||||
|
- **Confirm contract extended (`shared/protocol.ts`).** `ConfirmNationOpRequest.edits` is now `{ name?: string; playerPart?: number }` — `name` overrides a rename's target or a merge's merged name; `playerPart` picks which new part the player's nation becomes in a split (default 0). The `SplitOp` / `MergeOp` / `NationOp` doc comments and the `TurnResult.nationOps` doc now describe the *applied* semantics (no longer "a no-op until P4b").
|
||||||
|
- **Prompt (`server/engine.ts`).** The system prompt's JSON contract now shows all three `nationOps` shapes (rename / split / merge), and the HARD CONSTRAINTS gain one line per op explaining when to use it and the "≥2 parts" / "survivor must be an input" rules — so the model knows what it may propose and that each is a player-confirmed PROPOSAL.
|
||||||
|
- **Client (`src/components/NationOpDialog.tsx`).** The dialog now renders all three kinds with a Confirm button (no more "dismiss-only" split/merge rows): a **split** lists each part (name + the number of regions it was assigned) and, when the split's parent is the **player's own nation**, shows a **radio group** to pick which new part the player leads (preselected 0, so the choice is always valid); a **merge** shows the input nations as chips (the **survivor** highlighted) and an **editable name** for the merged state (defaulting to `into`). Rename keeps its editable-name behavior. The new `onConfirm(index, edits?)` signature threads `playerPart` through `ActionPanel` and `App.tsx` (`runConfirmNationOp`) to the API helper.
|
||||||
|
- **Defensive invariants.** All ops are **pure and never throw**: an unresolvable or ambiguous ref, too few parts, or a survivor that isn't an input makes the op a no-op on the world (the proposal is still resolved and removed on confirm, mirroring P4a's ambiguous-ref behavior). **Sole ownership** is preserved by construction (a key lands in at most one new part / one survivor), and `playerNationId` is always re-pointed to a still-existing nation. The **stub is unchanged** — it still never proposes a nation op (`nationOps: []`) — so, like P4a/P4b before it, the new paths are covered by **unit tests only** and the e2e suite (CI, `LLM_STUB=1`) is unaffected.
|
||||||
|
- **Tests (`server/engine.test.ts`).** New `freshNationId`, `applyNationOp (split)`, and `applyNationOp (merge)` suites: the partition (first-wins, leftovers-to-part-0, sole ownership, fresh ids/stats/relations, parent removed, relations cleanup, player-pointer move, clamped `playerPart`, `edits.name` on the chosen part), the merge (union territory, survivor stats, relations union with survivor-wins, removed ids dropped from everyone else, into-name, player-pointer move, and the four no-op guards), plus an updated prompt-contract test asserting all three op shapes. The old "split/merge is a no-op" P4a test is retargeted to a 1-part split. The unit suite grows **151 → 164**.
|
||||||
|
- **Verification.** Typecheck, lint, **164 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no nation-op assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — major nation changes (P4b, the final part of the epic). #16 was already closed (2026-10-12, after P3 shipped); the PR references it for traceability the same way P4a's PR #38 did ("Part of #16" / "Related: #16") — no auto-close.
|
||||||
|
|
||||||
|
## v0.23.0 — 2026-10-14 12:58
|
||||||
|
- **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b.
|
||||||
|
- **New `NationOp` union (`shared/protocol.ts`).** `rename` / `split` / `merge`, discriminated by `kind`. Each names its target via a `nationRef` the LLM may give as either a nation **id** or **name**. `TurnResult.nationOps` is a **required** `NationOp[]` (the model's proposals this turn); the confirm/reject request types (`NationOpConfirmRequest` `{index, edits?{name?}}`, `NationOpRejectRequest` `{index}`, response `{game}`) live here too.
|
||||||
|
- **`GameState.pendingNationOps` (`shared/types.ts`, type-only import of `NationOp`)** — the proposals awaiting the player's decision. A turn **appends** to it (never clears it on commit), so a proposal persists across turns until confirmed or rejected. Legacy SQLite rows and new games default it to `[]` (`server/db.ts`).
|
||||||
|
- **Engine (`server/engine.ts`), all pure.** `resolveNationRef` resolves a `nationRef` id-first, then a case-insensitive unique-name match (returns `null` for unknown/ambiguous — never throws). `validateNationOps` drops invalid ops defensively. `applyTurnResult` keeps existing pending ops and appends the turn's valid ones (a rename-only turn is `isMeaningful`, so it survives the retry/nudge path). `applyNationOp` mutates a clone (rename → `Nation.name` = edited value or the proposed `to`; split/merge are no-ops for now). `confirmNationOp`/`rejectNationOp` splice by index and bump `version` once (invalid index → `null`). The system prompt (both tool-mode and plain-mode) gains the `nationOps` JSON field and the rename-proposal rule.
|
||||||
|
- **Routes + client.** `POST /api/games/:id/nation-op/confirm` and `.../reject` (404 unknown game, 400 bad index), each returning `{ game }` and broadcasting a version-bumped SSE `state` — mirroring the existing queue add/remove routes. The "Your move" panel shows a "Nation proposals (N)" trigger that opens a dialog listing each pending op; a rename has an editable name field plus **Confirm**/**Reject**, and split/merge offer **Dismiss** (they aren't applied yet).
|
||||||
|
- **Stub + tests.** The deterministic stub **never** proposes a nation op (`nationOps: []`), so this path is covered by **unit tests only** (mirroring the P3 no-man's-land approach) — `server/engine.test.ts` gains suites for `resolveNationRef`, `validateNationOps`, `applyNationOp`/confirm/reject (incl. an invalid-index 400 and the version-bump), and the prompt contract. The unit suite grows **127 → 151**; the e2e suite is unaffected.
|
||||||
|
- **Verification.** Typecheck, lint, **151 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) has no nation-op assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — major nation changes (P4a); #16 stays open (P4b: split/merge).
|
||||||
|
|
||||||
|
## v0.22.0 — 2026-10-14 12:02
|
||||||
|
- **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game).
|
||||||
|
- **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming.
|
||||||
|
- **New required `GameState.unowned` field** (`shared/types.ts`): the cumulative list of keys released mid-game. A no-man's-land region is **distinct from a preset-unassigned region** — the presets assign only ~25–30% of the 1,796 sub-regions (e.g. `wwii-1939` starts with 411 owned / 1,385 unowned), so ~1,300 ownerless regions exist at init; those keep the existing faint look, and only `GameState.unowned` keys get the distinct one. Legacy SQLite rows saved before v0.22 load with the field defaulted to `[]` (`server/db.ts`), and game creation sets it to `[]`.
|
||||||
|
- **Map presentation (a new contract in `shared/protocol.ts`).** `regionFillStyle(owner, isNoMansLand)` + `unownedTooltipLabel` decide the look: an owned region is unchanged (nation color + `OWNED_STROKE`); a released region gets the distinct visible grey `NO_MANS_LAND_FILL = '#3f3f46'` (hue-less by construction, so it can never collide with a nation's `hsl(h 58% 46%)` color) with a brighter border (`NO_MANS_LAND_STROKE`) and the tooltip sub-line "No man's land"; every other ownerless region keeps the faint `rgba(148,163,184,0.05)` fill and "No owner". The client (`WorldMap`, via a new `unowned` prop from `App.tsx`) is the sole consumer of the distinction — the `RegionMap` simply has no entry for an unowned key (`buildRegionMap` is unchanged). Clicking a no-man's-land region does not change the dossier (there is no owner to focus), and a release/re-claim flashes like any other ownership change.
|
||||||
|
- **Stub + tests.** The deterministic stub is deliberately **unchanged** — it never releases a region (it always returns `unowned: []` and ignores the payload's `unowned` field), because its one-conquest behavior is what the e2e assertions rely on; the release/re-claim path is therefore covered by **unit tests only** (a new `applyTurnResult (no man's land)` suite: release, re-claim, release+claim of the same key, a double claim on a released key, the four no-op release cases, a legacy state without the field, and a `runTurn` end-to-end test with a narrated release; new `buildUserPrompt`/`buildSystemPrompt` contract tests; a presentation-contract suite in `server/map.test.ts`; a legacy-row normalization test in `server/db.test.ts`). The unit suite grows **105 → 127**.
|
||||||
|
- **No data-format wipe.** The `GameState` shape only gains a field that legacy rows load with, so existing saves remain compatible: the deploy is a plain `up -d --build` (no `down -v`).
|
||||||
|
- **Verification.** Typecheck, lint, **127 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no map-color/no-man's-land assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — no man's land (P3); #16 stays open.
|
||||||
|
|
||||||
|
## v0.21.0 — 2026-10-14 10:37
|
||||||
|
- **Part 2 of #16: territory now moves region-by-region along real land borders, and the LLM can verify borders with a live tool.** v0.19 made ownership region-keyed, but the model's `territoryChanges` were still applied wholesale — any key could jump to any nation, effectively moving whole countries. Now:
|
||||||
|
- **Region-adjacency manifest (Stage 1).** `scripts/build-region-adjacency.mjs` (`npm run build:region-adjacency`) computes which of the 1,796 sub-regions share a land border: per candidate pair, a cheap bbox-proximity prefilter (0.05° gap reject) then a `@turf/boolean-intersects` arbiter. The output `public/region-adjacency.json` is symmetric and deduped — **1,796 entries, 4,470 border pairs (8,940 directed), max degree 19; 1,727 regions have ≥1 neighbour, 69 don't** — and is deterministic/idempotent (sha256 `e9f1d475…`; re-running is byte-identical). Vite copies it into `dist/` for prod; `server/regions.ts` loads it (turf-free — plain `JSON.parse`, no `@turf` import in any `.ts`) and degrades gracefully when it is missing.
|
||||||
|
- **`getNeighbours` LLM tool (Stage 2).** `server/llm.ts` gains `LlmClient.chatWithTools` — a bounded OpenAI function-calling loop: on a `finish_reason: "tool_calls"` response the client runs the injected executor for every call, appends the assistant tool-call message plus one `tool` result message per call, and re-sends until a final `content` (max **6** tool rounds, then one last *no-tools* request forces the answer). It shares the retry/backoff/`reasoning_effort: "low"` machinery with `chat()`; a tool-call round is a success (never treated as truncation); malformed tool arguments become an `{"error":"invalid tool arguments…"}` tool message, so a bad model reply can never throw. `tools` is included in the request body only when non-empty, so plain `chat()` bodies (and their tests) are unchanged.
|
||||||
|
- **Engine wiring.** `runTurn` takes an optional `getNeighbours` resolver — the server's jump route passes one only when the manifest loaded (`hasAdjacency()`). With both the resolver and a `chatWithTools`-capable client, the turn runs the tool path: the system prompt gains the contiguity rules (a nation may only GAIN a key that borders its own territory; every key has exactly one owner) and advertises the tool; the user payload gains a `frontier` field — the player's `playerFrontier`, the sorted/deduped set of *other-nation* keys bordering the player's territory (the natural targets of a player-led conquest). Without the manifest, or with a minimal chat-only client, the turn falls back to the legacy plain `chat()` path — everything keeps working, the tool/frontier simply switch off.
|
||||||
|
- **Sole-ownership enforcement.** `applyTurnResult` now applies territory snapshot-first: before mutating anything it computes each key's current owner, and a key transfers only when (a) exactly one nation claims to gain it (a double claim is rejected — the key stays), (b) it has exactly one current owner (pre-existing double ownership is never touched), and (c) the owner is not the gainer (self-gain is a no-op). A transfer removes the key from all nations first, then grants it to the gainer; lone `lose` entries are no-ops. Every key therefore ends the turn with exactly one owner — it can never be orphaned or duplicated, and a bad model answer degrades to "no transfer", never to a corrupt map.
|
||||||
|
- **Deterministic stub.** The stub now makes one contiguous conquest per turn: the player gains `frontier[0]` (sorted → stable) and its owner loses it. It answers plain content (no tool calls), so it naturally bypasses the tool loop in a single round; with an empty frontier (no controllable border — e.g. the UK in `wwii-1939`, whose only external neighbour is Ireland) nothing transfers and every existing e2e assertion stays stable.
|
||||||
|
- **No data-format change — no save wipe.** The `TerritoryChange` wire shape is unchanged and saved games load as-is, so the deploy is a plain rebuild (no `down -v`).
|
||||||
|
- **Verification.** Typecheck, lint, **105 unit tests** (79 baseline + 26 new: tool-loop protocol — echo, parsed executor args, bounded rounds, malformed args, retry; engine tool-path wiring + fallback; `playerFrontier`; executor degradation; sole-ownership invariants; `neighboursOf`/`hasAdjacency`), the production build, and all **14 e2e** tests pass.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — P3 (region rename/split/merge) and P4 follow; #16 stays open.
|
||||||
|
|
||||||
|
## v0.20.0 — 2026-10-14 09:30
|
||||||
|
- **Fixed: v0.19.0's region-level ownership did not activate in the production image.** The server-side region loader (`server/regions.ts`) only looked for the manifest + geo in `public/`, but in the Docker image Vite copies `public/*` → `dist/` and no `public/` directory is kept — so `loadRegions` found nothing, `expandCountriesToKeys` degraded to a no-op, and freshly-created games kept raw ISO_A3 codes (`DEU`, `GBR,IRL,IND`, …) instead of expanding to region keys. The choropleth therefore still rendered whole-country ownership and the model's region-key contract was inactive in prod (the client's own `/region-keys.json` fetch still worked, because the client is served from `dist/`). `loadRegions` now probes `public/` (dev) then `dist/` (prod, == `clientDir`) and uses the first directory holding both files, so one build works in both environments. A new test covers the prod-only (`dist/`), dev (`public/`), and empty-root (no data) layouts.
|
||||||
|
- **Deploy:** `docker compose down -v` (drops the `history-data` volume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) then `up -d --build`.
|
||||||
|
|
||||||
|
## v0.19.0 — 2026-10-14 09:14
|
||||||
|
- **Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions.** Previously a nation owned *whole countries* (a `territory` list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable **region key** — `<name-slug>-<4char>`, e.g. `moselle-rygy`, `ain-lqtt`, `western-alun` — generated deterministically by `scripts/build-region-keys.mjs` into `public/region-keys.json` (**1,796 unique keys** over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → `-`) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields `western-alun` / `western-qwvr` / `western-adsp` / …; an accented name like Shkodër becomes `shkoder-gmnr`).
|
||||||
|
- **The map, the model, and the LLM all now reason in region keys.** `Nation.territory` and the `RegionMap` are keyed by region key; the choropleth resolves each sub-region's owner by its stamped `properties.regionKey` (never the repacked numeric feature `id`, never the raw sub-region id — the #6 guard, now region-keyed). A new `server/regions.ts` (turf-free) loads the manifest + geo once at boot and **expands each nation's country list to all its sub-regions' keys at game creation**, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a `regions` reference table (region key → "<name> (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership.
|
||||||
|
- **The dossier shows region names.** NationPanel's territory chips display each sub-region's human name (e.g. "Ain", "Wales", "Moselle") with the raw key in the tooltip / data attribute, instead of bare codes.
|
||||||
|
- **⚠ Breaking — pre-v0.19 saved games are dropped.** The `territory` field's meaning changed from ISO_A3 country codes to region keys, so a game saved before this version can't be read by it. Deploy runs `docker compose down -v` (dropping the `history-data` volume) then `up -d --build`; there is no migration — start fresh after deploying.
|
||||||
|
- **Supersedes the abandoned `feat/sub-region-territory` branch**, which prototyped raw feature-id keys (`DEU-03`); this version uses the slugged, human-readable key scheme instead.
|
||||||
|
- **Verification.** Typecheck, lint, and the unit suite (now **71 tests across 7 files**: +`server/regions.test.ts`, a rewritten `server/map.test.ts`, +2 in `engine.test.ts`) all pass; the production build is clean. The deterministic `LLM_STUB` and the format-agnostic e2e suite (legend/stats/dates) are unchanged. **#16 stays open** — P2 (LLM tools / function-calling for reliable region ops) and the no-man's-land rename/split/merge phases follow.
|
||||||
|
|
||||||
|
## 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
|
## v0.4.0 — 2026-10-11 16:02
|
||||||
|
|
||||||
- **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional `setGame` updater, so it is stale-closure-proof).
|
- **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional `setGame` updater, so it is stale-closure-proof).
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic scenario generation — end to end (v0.31, #52). Fully deterministic
|
||||||
|
* under the SCENARIO stub (the Playwright webServer boots with LLM_STUB=1, so
|
||||||
|
* the two generation calls ride the deterministic transport in
|
||||||
|
* server/stubScenario.ts — the turn stub in server/stub.ts is untouched).
|
||||||
|
*
|
||||||
|
* The stub returns a FIXED 3-nation world and a FIXED 3-checkpoint list for
|
||||||
|
* the generate step, and — on the finalize step — refines that world in a
|
||||||
|
* deterministic way that depends on which checkpoints the player approved
|
||||||
|
* (each approved checkpoint naming one of the fixed nations, by its first 4
|
||||||
|
* letters, raises that nation's stability by 5). So the whole two-step
|
||||||
|
* "Create scenario" path plus the resulting game is reproducible:
|
||||||
|
*
|
||||||
|
* home → "Create scenario" → pick the Near Future era (2035) + a description
|
||||||
|
* → generate (draft: alba/boreas/gamma + 3 checkpoints) → review (remove the
|
||||||
|
* 3rd checkpoint, keep the first two) → lead Boreas → finalize → a saved,
|
||||||
|
* playable game (turn 1, date 2035-01-01, player Boreas) → one empty jump
|
||||||
|
* (turn 2, date 2035-01-03, Boreas military 55→60, allied with Alba, and no
|
||||||
|
* territory change — Russia shares no land border with France or Spain).
|
||||||
|
*
|
||||||
|
* The e2e webServer shares one SQLite (data/e2e) across tests, so the test
|
||||||
|
* captures the *specific* id of the game it creates (from the POST
|
||||||
|
* /api/scenarios/commit response) rather than assuming "the only game".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The saved-game shape the assertions read back from GET /api/games/<id>. */
|
||||||
|
interface SavedGame {
|
||||||
|
id: string;
|
||||||
|
presetId: string;
|
||||||
|
presetTitle: string;
|
||||||
|
playerNationId: string;
|
||||||
|
turn: number;
|
||||||
|
date: string;
|
||||||
|
version: number;
|
||||||
|
pending: unknown[];
|
||||||
|
unowned: string[];
|
||||||
|
pendingNationOps: unknown[];
|
||||||
|
markers: unknown[];
|
||||||
|
events: Array<{ type: string; outcome: string; title: string }>;
|
||||||
|
nations: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
stats: { military: number; economy: number; stability: number; population: number };
|
||||||
|
territory: string[];
|
||||||
|
relations: Record<string, string>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
regionStats: Record<
|
||||||
|
string,
|
||||||
|
{ military: number; economy: number; stability: number; population: number }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the "Create scenario" form from the home screen. */
|
||||||
|
async function openScenarioForm(page: Page): Promise<void> {
|
||||||
|
await page.goto('/');
|
||||||
|
// Home: the create-scenario card is additive (first in the grid); the five
|
||||||
|
// static presets are still all present alongside it.
|
||||||
|
await expect(page.getByTestId('create-scenario-card')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('preset-card')).toHaveCount(5);
|
||||||
|
await page.getByTestId('create-scenario').click();
|
||||||
|
await expect(page.getByTestId('scenario-form')).toBeVisible({ timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('History — dynamic scenario generation (LLM_STUB=1)', () => {
|
||||||
|
test('the era template manifest renders three light templates', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await openScenarioForm(page);
|
||||||
|
|
||||||
|
// Exactly three era-template cards, each carrying its id and default year.
|
||||||
|
const cards = page.getByTestId('template-card');
|
||||||
|
await expect(cards).toHaveCount(3, { timeout: 15000 });
|
||||||
|
await expect(page.locator('[data-template-id="ww2"]')).toContainText('1939');
|
||||||
|
await expect(page.locator('[data-template-id="present"]')).toContainText('2026');
|
||||||
|
await expect(page.locator('[data-template-id="near-future"]')).toContainText('2035');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full create → review → finalize → play flow is deterministic', async ({ page }) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
await openScenarioForm(page);
|
||||||
|
|
||||||
|
// --- 1 · Pick the Near Future era (its default year pre-fills the field).
|
||||||
|
const nf = page.locator('[data-template-id="near-future"]');
|
||||||
|
await nf.click();
|
||||||
|
await expect(nf).toHaveAttribute('data-selected', 'true');
|
||||||
|
// The year field is pre-filled from the era (editable, a real value).
|
||||||
|
await expect(page.getByTestId('scenario-year')).toHaveValue('2035');
|
||||||
|
|
||||||
|
// --- 2 · Describe the world.
|
||||||
|
const description =
|
||||||
|
'A cold space race decides the new cold war: two orbital blocs hold the arctic shipping lanes at knife-edge.';
|
||||||
|
await page.getByTestId('scenario-description').fill(description);
|
||||||
|
|
||||||
|
// --- Step 1 · Generate the draft world + the advisor's checkpoint list.
|
||||||
|
const generate = page.getByTestId('generate-scenario');
|
||||||
|
await expect(generate).toBeEnabled();
|
||||||
|
const [draftResp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/draft',
|
||||||
|
),
|
||||||
|
generate.click(),
|
||||||
|
]);
|
||||||
|
expect(draftResp.status()).toBe(200);
|
||||||
|
const draft = (await draftResp.json()) as {
|
||||||
|
world: {
|
||||||
|
nations: Array<{ id: string; name: string; territory: string[] }>;
|
||||||
|
};
|
||||||
|
checkpoints: string[];
|
||||||
|
};
|
||||||
|
// The stub's fixed 3-nation world (FRA / RUS / ESP) and 3 checkpoints.
|
||||||
|
expect(draft.world.nations.map((n) => n.id).sort()).toEqual(['alba', 'boreas', 'gamma']);
|
||||||
|
expect(draft.world.nations.find((n) => n.id === 'boreas')?.territory).toEqual(['RUS']);
|
||||||
|
expect(draft.checkpoints).toHaveLength(3);
|
||||||
|
|
||||||
|
// --- Review screen: the generated world + the checkpoint checklist.
|
||||||
|
await expect(page.getByTestId('scenario-review')).toBeVisible({ timeout: 15000 });
|
||||||
|
// Header shows the era's name + year and the description.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Near Future' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('scenario-review')).toContainText('2035');
|
||||||
|
await expect(page.getByTestId('scenario-review')).toContainText(description);
|
||||||
|
// The three draft nations (with their ISO_A3 territory) and the backstory.
|
||||||
|
// Scope to the draft-nation cards (the nation-pick cards share the
|
||||||
|
// data-nation-id attribute but carry a different testid).
|
||||||
|
await expect(page.getByTestId('draft-nation')).toHaveCount(3);
|
||||||
|
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="alba"]')).toContainText('FRA');
|
||||||
|
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="boreas"]')).toContainText('RUS');
|
||||||
|
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="gamma"]')).toContainText('ESP');
|
||||||
|
await expect(page.getByTestId('draft-event')).toHaveCount(1);
|
||||||
|
// Three checkpoints, all approved by default, in the stub's fixed order.
|
||||||
|
const cps = page.getByTestId('checkpoint-item');
|
||||||
|
await expect(cps).toHaveCount(3);
|
||||||
|
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
||||||
|
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
||||||
|
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'true');
|
||||||
|
await expect(cps.nth(0)).toContainText('Alba dominates');
|
||||||
|
await expect(cps.nth(1)).toContainText('Boreas fortifies');
|
||||||
|
await expect(cps.nth(2)).toContainText('Gamma brokers');
|
||||||
|
// Three nation-pick cards; finalize is disabled until a nation is chosen.
|
||||||
|
await expect(page.getByTestId('scenario-nation-card')).toHaveCount(3);
|
||||||
|
await expect(page.getByTestId('finalize-scenario')).toBeDisabled();
|
||||||
|
|
||||||
|
// --- Review: remove checkpoint 3, keep 1 + 2.
|
||||||
|
await cps.nth(2).getByTestId('checkpoint-toggle').click();
|
||||||
|
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'false');
|
||||||
|
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
||||||
|
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
||||||
|
await expect(page.getByTestId('scenario-review')).toContainText('2 of 3 approved');
|
||||||
|
|
||||||
|
// --- Review: choose Boreas (Russia) to lead.
|
||||||
|
const boreasCard = page.locator('[data-testid="scenario-nation-card"][data-nation-id="boreas"]');
|
||||||
|
await boreasCard.getByTestId('scenario-lead-nation').click();
|
||||||
|
await expect(boreasCard).toHaveAttribute('data-selected', 'true');
|
||||||
|
await expect(page.getByTestId('finalize-scenario')).toBeEnabled();
|
||||||
|
|
||||||
|
// --- Step 2 · Finalize: commit the approved draft into a saved game.
|
||||||
|
const [commitResp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/commit',
|
||||||
|
),
|
||||||
|
page.getByTestId('finalize-scenario').click(),
|
||||||
|
]);
|
||||||
|
expect(commitResp.status()).toBe(200);
|
||||||
|
const { id } = (await commitResp.json()) as { id: string };
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
|
||||||
|
// --- The new game mounts at turn 1, leading Boreas, dated 2035-01-01.
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible({ timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
await expect(page.getByTestId('game-date')).toHaveText('2035-01-01');
|
||||||
|
await expect(page.getByTestId('player-nation')).toHaveText('Boreas');
|
||||||
|
// The scenario title (the era name) is shown in the top bar.
|
||||||
|
await expect(page.getByTestId('top-bar')).toContainText('Near Future');
|
||||||
|
// Boreas's stats: the finalized world (its approved checkpoint raised
|
||||||
|
// stability 50→55); per-region stats are seeded to equal the derived.
|
||||||
|
await expect(page.getByTestId('stat-military')).toHaveText('55');
|
||||||
|
await expect(page.getByTestId('stat-economy')).toHaveText('70');
|
||||||
|
await expect(page.getByTestId('stat-stability')).toHaveText('55');
|
||||||
|
await expect(page.getByTestId('stat-population')).toHaveText('60');
|
||||||
|
// One backstory event; three nations on the legend.
|
||||||
|
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'Boreas' })).toBeVisible();
|
||||||
|
// The deep link lands on this exact game.
|
||||||
|
await waitHash(page, `#/game/${id}`);
|
||||||
|
|
||||||
|
// --- One empty jump: the world advances on its own (the turn stub).
|
||||||
|
// The button sits at the bottom of a scrollable window body and may be
|
||||||
|
// outside the viewport; a native DOM click bypasses the viewport check.
|
||||||
|
await page.getByTestId('jump-forward').evaluate((el) => (el as HTMLElement).click());
|
||||||
|
|
||||||
|
// Turn 2, date +2 days, Boreas military 55→60.
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('stat-military')).toHaveText('60', { timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('game-date')).toHaveText('2035-01-03', { timeout: 20000 });
|
||||||
|
// Two events now (the empty-round "world moves on" on top, then the
|
||||||
|
// backstory); both political/neutral.
|
||||||
|
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
||||||
|
await expect(page.getByTestId('event-item').first()).toContainText('The world moves on');
|
||||||
|
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
|
||||||
|
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
|
||||||
|
await expect(page.getByTestId('event-item').nth(1)).toContainText('The new order takes shape');
|
||||||
|
// No territory change: Russia shares no land border with France or Spain,
|
||||||
|
// so the frontier is empty and no conquest is emitted.
|
||||||
|
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
||||||
|
|
||||||
|
// --- The saved game is a coherent dynamic GameState on the server.
|
||||||
|
const saved = await page.evaluate(async (gid) => {
|
||||||
|
const r = await fetch(`/api/games/${gid}`);
|
||||||
|
return { status: r.status, body: (await r.json()) as { game: SavedGame } };
|
||||||
|
}, id);
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
const g = saved.body.game;
|
||||||
|
expect(g.presetId).toBe('dynamic');
|
||||||
|
expect(g.presetTitle).toBe('Near Future');
|
||||||
|
expect(g.playerNationId).toBe('boreas');
|
||||||
|
expect(g.turn).toBe(2);
|
||||||
|
expect(g.date).toBe('2035-01-03');
|
||||||
|
expect(g.version).toBe(2); // created at 1, bumped to 2 by the jump
|
||||||
|
expect(g.pending).toEqual([]);
|
||||||
|
expect(g.unowned).toEqual([]);
|
||||||
|
expect(g.pendingNationOps).toEqual([]);
|
||||||
|
expect(g.markers).toEqual([]);
|
||||||
|
expect(g.events).toHaveLength(2);
|
||||||
|
// Boreas after the jump: military bumped to 60, the rest untouched.
|
||||||
|
expect(g.nations.boreas.stats).toEqual({
|
||||||
|
military: 60,
|
||||||
|
economy: 70,
|
||||||
|
stability: 55,
|
||||||
|
population: 60,
|
||||||
|
});
|
||||||
|
// The stub allies the player with the first other nation (Alba).
|
||||||
|
expect(g.nations.boreas.relations.alba).toBe('ally');
|
||||||
|
// Every owned region key carries a seeded per-region stats entry.
|
||||||
|
const allKeys = new Set<string>();
|
||||||
|
for (const n of Object.values(g.nations)) for (const k of n.territory) allKeys.add(k);
|
||||||
|
expect(allKeys.size).toBeGreaterThan(0);
|
||||||
|
for (const k of allKeys) {
|
||||||
|
expect(g.regionStats[k]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+224
@@ -419,3 +419,227 @@ test.describe('History — URL deep-linking (#/game/<id>)', () => {
|
|||||||
await waitHash(page, '#/');
|
await waitHash(page, '#/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HUD revamp (issues #4 and #5). The floating "Map" zoom window is gone — the
|
||||||
|
* full-bleed map is driven directly by wheel/trackpad + drag. The top bar is a
|
||||||
|
* *pinned* element: fixed, non-movable, centered at the top a few px down. The
|
||||||
|
* dossier / feed / action panels can each be docked into a full-height left or
|
||||||
|
* right pane (persisted in `localStorage`); the legend stays a small floating
|
||||||
|
* widget.
|
||||||
|
*/
|
||||||
|
test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map window)', () => {
|
||||||
|
test('Map window is gone; top bar is pinned top-center; panels dock left/right', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
|
||||||
|
const vw = page.viewportSize()!.width;
|
||||||
|
const vh = page.viewportSize()!.height;
|
||||||
|
|
||||||
|
// --- #4: the "Map" zoom window is gone --------------------------------
|
||||||
|
// No zoom window element, and no zoom-in/zoom-out buttons anywhere on the
|
||||||
|
// game screen.
|
||||||
|
await expect(page.locator('[data-float-window="zoom"]')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('zoom-in')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('zoom-out')).toHaveCount(0);
|
||||||
|
|
||||||
|
// --- #5a: the top bar is pinned, centered at the top ------------------
|
||||||
|
const topBar = page.getByTestId('top-bar');
|
||||||
|
await expect(topBar).toBeVisible();
|
||||||
|
const barBox = (await topBar.boundingBox())!;
|
||||||
|
// Horizontally centered: x ≈ (viewportWidth − width) / 2 …
|
||||||
|
expect(Math.abs(barBox.x - (vw - barBox.width) / 2)).toBeLessThanOrEqual(3);
|
||||||
|
// …and a few px down from the top edge (y ≈ 8).
|
||||||
|
expect(barBox.y).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(barBox.y).toBeLessThanOrEqual(10);
|
||||||
|
|
||||||
|
// A pointer-drag attempted on the pinned bar must not move it (it has no
|
||||||
|
// drag handlers; the map behind it never receives a mousedown).
|
||||||
|
await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(barBox.x + barBox.width / 2 + 60, barBox.y + barBox.height / 2 + 40, {
|
||||||
|
steps: 6,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
const barBoxAfter = (await topBar.boundingBox())!;
|
||||||
|
expect(Math.abs(barBoxAfter.x - barBox.x)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(barBoxAfter.y - barBox.y)).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// --- #5b: the dossier docks into a full-height left pane --------------
|
||||||
|
const dossier = page.getByTestId('panel-dossier');
|
||||||
|
await dossier.getByTestId('dock-left').click();
|
||||||
|
const dBox = (await dossier.boundingBox())!;
|
||||||
|
// Pinned to the left edge, filling the viewport height.
|
||||||
|
expect(dBox.x).toBeLessThanOrEqual(2);
|
||||||
|
expect(dBox.y).toBeLessThanOrEqual(2);
|
||||||
|
expect(Math.abs(dBox.height - vh)).toBeLessThanOrEqual(3);
|
||||||
|
expect(Math.abs(dBox.width - 300)).toBeLessThanOrEqual(3);
|
||||||
|
|
||||||
|
// --- #5b: the event feed docks into a full-height right pane ----------
|
||||||
|
const feed = page.getByTestId('panel-feed');
|
||||||
|
await feed.getByTestId('dock-right').click();
|
||||||
|
const fBox = (await feed.boundingBox())!;
|
||||||
|
// Pinned to the right edge: its right side sits on the viewport edge.
|
||||||
|
expect(Math.abs(fBox.x + fBox.width - vw)).toBeLessThanOrEqual(3);
|
||||||
|
expect(Math.abs(fBox.height - vh)).toBeLessThanOrEqual(3);
|
||||||
|
|
||||||
|
// The "Your move" deck is still a floating panel (we never docked it).
|
||||||
|
await expect(page.getByTestId('panel-action')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dock choice survives a page reload (persisted in localStorage)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
const vh = page.viewportSize()!.height;
|
||||||
|
|
||||||
|
// Dock the dossier into the left pane.
|
||||||
|
const dossier = page.getByTestId('panel-dossier');
|
||||||
|
await dossier.getByTestId('dock-left').click();
|
||||||
|
const before = (await dossier.boundingBox())!;
|
||||||
|
expect(before.x).toBeLessThanOrEqual(2);
|
||||||
|
expect(Math.abs(before.height - vh)).toBeLessThanOrEqual(3);
|
||||||
|
|
||||||
|
// Reload: the persisted dock choice must restore the docked layout, and the
|
||||||
|
// deep link restores the game.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1', { timeout: 20000 });
|
||||||
|
const after = (await page.getByTestId('panel-dossier').boundingBox())!;
|
||||||
|
expect(after.x).toBeLessThanOrEqual(2);
|
||||||
|
expect(Math.abs(after.height - vh)).toBeLessThanOrEqual(3);
|
||||||
|
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advisor "thinking" indicator (issue #15). Asking the advisor is a *separate*
|
||||||
|
* LLM call with its own `advisorBusy` flag — it must never block the turn flow.
|
||||||
|
* Under the deterministic stub the fetch resolves in milliseconds, so the
|
||||||
|
* transient thinking state is too short to assert mid-flight reliably. Instead
|
||||||
|
* we assert the *terminal* states, which are deterministic: before the fetch the
|
||||||
|
* button is enabled and the hint is absent; after it, the advisor list is
|
||||||
|
* populated (5 canned ideas), the hint is gone, the button is re-enabled, and
|
||||||
|
* the top bar is back to "Idle".
|
||||||
|
*/
|
||||||
|
test.describe('History — Advisor thinking indicator (#15)', () => {
|
||||||
|
test('asking the advisor populates the list and clears the busy state', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
|
||||||
|
// Open the Advisor tab.
|
||||||
|
await page.getByTestId('tab-advisor').click();
|
||||||
|
|
||||||
|
// Before any fetch: the button is enabled, no thinking hint, no results yet.
|
||||||
|
const ask = page.getByTestId('ask-advisor');
|
||||||
|
await expect(ask).toBeEnabled();
|
||||||
|
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('advisor-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Ask the advisor (with an optional focus). The stub returns 5 canned ideas.
|
||||||
|
await page.getByTestId('advisor-prompt').fill('the nuclear standoff');
|
||||||
|
await ask.click();
|
||||||
|
|
||||||
|
// Terminal state: the result populates the advisor list.
|
||||||
|
await expect(page.getByTestId('advisor-list')).toBeVisible({ timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('advisor-item')).toHaveCount(5, { timeout: 20000 });
|
||||||
|
|
||||||
|
// The fetch finished: the thinking hint is gone and the button is re-enabled.
|
||||||
|
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||||
|
await expect(ask).toBeEnabled();
|
||||||
|
|
||||||
|
// Nothing is in flight now, so the top bar reports idle (not the turn flow).
|
||||||
|
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a saved game (#20): a saved game can be deleted from the home screen
|
||||||
|
* (with a confirmation dialog), and loading a non-existent / deleted game id
|
||||||
|
* (directly or via a stale open tab) returns the user to the home screen.
|
||||||
|
*/
|
||||||
|
test.describe('History — remove a saved game & redirect (#20)', () => {
|
||||||
|
test('deleting a saved game from the home screen removes it (with confirmation)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const id = await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
|
||||||
|
// Back to the home screen, where the just-created game appears in "Saved games".
|
||||||
|
await page.getByTestId('back-to-scenarios').click();
|
||||||
|
await expect(page.getByTestId('saved-games')).toBeVisible({ timeout: 15000 });
|
||||||
|
const card = page.locator(`[data-game-id="${id}"]`);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Opening the confirm dialog…
|
||||||
|
await card.getByTestId('delete-game').click();
|
||||||
|
const dialog = page.getByTestId('delete-game-confirm-dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
// …and cancelling leaves the game in place.
|
||||||
|
await page.getByTestId('delete-game-cancel').click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Confirming the delete removes it: the DELETE succeeds, the card disappears,
|
||||||
|
// and a subsequent fetch reports the game gone.
|
||||||
|
await card.getByTestId('delete-game').click();
|
||||||
|
await expect(page.getByTestId('delete-game-confirm-dialog')).toBeVisible();
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.request().method() === 'DELETE' &&
|
||||||
|
new URL(r.url()).pathname === `/api/games/${id}`,
|
||||||
|
),
|
||||||
|
page.getByTestId('delete-game-confirm').click(),
|
||||||
|
]);
|
||||||
|
expect((await resp.json()) as { deleted: boolean }).toEqual({ deleted: true });
|
||||||
|
await expect(page.getByTestId('delete-game-confirm-dialog')).toHaveCount(0);
|
||||||
|
await expect(card).toHaveCount(0);
|
||||||
|
|
||||||
|
// The game is gone from the server too.
|
||||||
|
const status = await page.evaluate(async (gid) => {
|
||||||
|
const r = await fetch(`/api/games/${gid}`);
|
||||||
|
return r.status;
|
||||||
|
}, id);
|
||||||
|
expect(status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting an open game sends the active tab home; a deep link lands on home', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const id = await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
const origin = await page.evaluate(() => location.origin);
|
||||||
|
|
||||||
|
// Delete the game straight through the API while its tab is open. The server
|
||||||
|
// broadcasts an SSE `error` and the client's useSse onError handler returns
|
||||||
|
// this live tab to the home screen.
|
||||||
|
const delStatus = await page.evaluate(
|
||||||
|
async (gid) => (await fetch(`/api/games/${gid}`, { method: 'DELETE' })).status,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
expect(delStatus).toBe(200);
|
||||||
|
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||||
|
await waitHash(page, '#/');
|
||||||
|
|
||||||
|
// A brand-new page deep-linking to the now-deleted id also lands on home.
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.goto(`${origin}/#/game/${id}`);
|
||||||
|
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||||
|
await waitHash(page2, '#/');
|
||||||
|
await page2.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Generated
+2462
-2
File diff suppressed because it is too large
Load Diff
+23
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.4.0",
|
"version": "0.38.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,19 @@
|
|||||||
"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",
|
||||||
|
"split:regions": "node scripts/split-arkhangel.mjs",
|
||||||
|
"split:regions:band": "node scripts/split-arkhangel-band.mjs",
|
||||||
|
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||||
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
|
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||||
|
"build:region-geo": "node scripts/build-region-geo.mjs",
|
||||||
|
"build:region-resources": "node scripts/build-region-resources.mjs",
|
||||||
|
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
@@ -38,6 +51,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",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ww2",
|
||||||
|
"name": "World War II",
|
||||||
|
"year": 1939,
|
||||||
|
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "present",
|
||||||
|
"name": "Present Day",
|
||||||
|
"year": 2026,
|
||||||
|
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, AI, and great-power rivalry shape every decision."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "near-future",
|
||||||
|
"name": "Near Future",
|
||||||
|
"year": 2035,
|
||||||
|
"eraDescription": "Autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "near-future",
|
||||||
|
"name": "Near Future",
|
||||||
|
"year": 2035,
|
||||||
|
"eraDescription": "A near-future world: autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances as the great powers reach for the next horizon.",
|
||||||
|
"suggestedPowers": [
|
||||||
|
{ "name": "North American Union", "region": "USA", "note": "The industrial-technological core of the West." },
|
||||||
|
{ "name": "Chinese Federation", "region": "CHN", "note": "The integrated superpower of the Pacific." },
|
||||||
|
{ "name": "European Confederation", "region": "FRA", "note": "The united European bloc." },
|
||||||
|
{ "name": "Russian Union", "region": "RUS", "note": "The resource frontier state." }
|
||||||
|
],
|
||||||
|
"tensions": [
|
||||||
|
"Autonomous weapons and the new arms race",
|
||||||
|
"Climate migration and resource wars",
|
||||||
|
"The contest for space and orbital dominance"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "present",
|
||||||
|
"name": "Present Day",
|
||||||
|
"year": 2026,
|
||||||
|
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, artificial intelligence, and great-power rivalry shape every decision the leaders of the age must make.",
|
||||||
|
"suggestedPowers": [
|
||||||
|
{ "name": "United States", "region": "USA", "note": "The military-technological leader of the Western bloc." },
|
||||||
|
{ "name": "People's Republic of China", "region": "CHN", "note": "The industrial giant pressing on the Western Pacific." },
|
||||||
|
{ "name": "Russian Federation", "region": "RUS", "note": "A resource-rich power testing its neighbors' resolve." },
|
||||||
|
{ "name": "European Union", "region": "FRA", "note": "A large, internally divided economic bloc." }
|
||||||
|
],
|
||||||
|
"tensions": [
|
||||||
|
"US–China tech and trade rivalry",
|
||||||
|
"The war in Europe and the eastern flank",
|
||||||
|
"AI and cyber dominance"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"id": "ww2",
|
||||||
|
"name": "World War II",
|
||||||
|
"year": 1939,
|
||||||
|
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength, and the great powers watch — and arm — from across the oceans.",
|
||||||
|
"suggestedPowers": [
|
||||||
|
{ "name": "German Reich", "region": "DEU", "note": "A militarized Reich at the heart of the continent, its army the most battle-hardened in Europe." },
|
||||||
|
{ "name": "United Kingdom", "region": "GBR", "note": "An island power that commands the seas but is cut off from the continent." },
|
||||||
|
{ "name": "Soviet Union", "region": "RUS", "note": "A vast, fragile industrial giant on the eastern frontier." },
|
||||||
|
{ "name": "United States", "region": "USA", "note": "An industrial colossus, still politically divided over entry into the war." }
|
||||||
|
],
|
||||||
|
"tensions": [
|
||||||
|
"The Axis versus the Allies",
|
||||||
|
"The race for continental resources and sea lanes",
|
||||||
|
"The uneasy balance before the bomb"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
{
|
||||||
|
"ABW": 106314,
|
||||||
|
"AFG": 38041754,
|
||||||
|
"AGO": 31825295,
|
||||||
|
"AIA": 14731,
|
||||||
|
"ALB": 2854191,
|
||||||
|
"ALD": 29884,
|
||||||
|
"AND": 77142,
|
||||||
|
"ARE": 9770529,
|
||||||
|
"ARG": 44938712,
|
||||||
|
"ARM": 2957731,
|
||||||
|
"ASM": 55312,
|
||||||
|
"ATA": 4490,
|
||||||
|
"ATF": 140,
|
||||||
|
"ATG": 97118,
|
||||||
|
"AUS": 25364307,
|
||||||
|
"AUT": 8877067,
|
||||||
|
"AZE": 10023318,
|
||||||
|
"BDI": 11530580,
|
||||||
|
"BEL": 11484055,
|
||||||
|
"BEN": 11801151,
|
||||||
|
"BFA": 20321378,
|
||||||
|
"BGD": 163046161,
|
||||||
|
"BGR": 6975761,
|
||||||
|
"BHR": 1641172,
|
||||||
|
"BHS": 389482,
|
||||||
|
"BIH": 3301000,
|
||||||
|
"BLM": 9961,
|
||||||
|
"BLR": 9466856,
|
||||||
|
"BLZ": 390353,
|
||||||
|
"BMU": 63918,
|
||||||
|
"BOL": 11513100,
|
||||||
|
"BRA": 211049527,
|
||||||
|
"BRB": 287025,
|
||||||
|
"BRN": 433285,
|
||||||
|
"BTN": 763092,
|
||||||
|
"BWA": 2303697,
|
||||||
|
"CAF": 4745185,
|
||||||
|
"CAN": 37589262,
|
||||||
|
"CHE": 8574832,
|
||||||
|
"CHL": 18952038,
|
||||||
|
"CHN": 1397715000,
|
||||||
|
"CIV": 25716544,
|
||||||
|
"CMR": 25876380,
|
||||||
|
"CNM": 10000,
|
||||||
|
"COD": 86790567,
|
||||||
|
"COG": 5380508,
|
||||||
|
"COK": 17459,
|
||||||
|
"COL": 50339443,
|
||||||
|
"COM": 850886,
|
||||||
|
"CPV": 549935,
|
||||||
|
"CRI": 5047561,
|
||||||
|
"CSI": 4,
|
||||||
|
"CUB": 11333483,
|
||||||
|
"CUW": 157538,
|
||||||
|
"CYM": 64948,
|
||||||
|
"CYN": 326000,
|
||||||
|
"CYP": 1198575,
|
||||||
|
"CZE": 10669709,
|
||||||
|
"DEU": 83132799,
|
||||||
|
"DJI": 973560,
|
||||||
|
"DMA": 71808,
|
||||||
|
"DNK": 5818553,
|
||||||
|
"DOM": 10738958,
|
||||||
|
"DZA": 43053054,
|
||||||
|
"ECU": 17373662,
|
||||||
|
"EGY": 100388073,
|
||||||
|
"ERI": 6081196,
|
||||||
|
"ESB": 7850,
|
||||||
|
"ESP": 47076781,
|
||||||
|
"EST": 1326590,
|
||||||
|
"ETH": 112078730,
|
||||||
|
"FIN": 5520314,
|
||||||
|
"FJI": 889953,
|
||||||
|
"FLK": 3398,
|
||||||
|
"FRA": 67059887,
|
||||||
|
"FRO": 48678,
|
||||||
|
"FSM": 113815,
|
||||||
|
"GAB": 2172579,
|
||||||
|
"GBR": 66834405,
|
||||||
|
"GEO": 3720382,
|
||||||
|
"GGY": 62792,
|
||||||
|
"GHA": 30417856,
|
||||||
|
"GIB": 33701,
|
||||||
|
"GIN": 12771246,
|
||||||
|
"GMB": 2347706,
|
||||||
|
"GNB": 1920922,
|
||||||
|
"GNQ": 1355986,
|
||||||
|
"GRC": 10716322,
|
||||||
|
"GRD": 112003,
|
||||||
|
"GRL": 56225,
|
||||||
|
"GTM": 16604026,
|
||||||
|
"GUM": 167294,
|
||||||
|
"GUY": 782766,
|
||||||
|
"HKG": 7507400,
|
||||||
|
"HND": 9746117,
|
||||||
|
"HRV": 4067500,
|
||||||
|
"HTI": 11263077,
|
||||||
|
"HUN": 9769949,
|
||||||
|
"IDN": 270625568,
|
||||||
|
"IMN": 84584,
|
||||||
|
"IND": 1366417754,
|
||||||
|
"IOA": 2387,
|
||||||
|
"IOT": 3000,
|
||||||
|
"IRL": 4941444,
|
||||||
|
"IRN": 82913906,
|
||||||
|
"IRQ": 39309783,
|
||||||
|
"ISL": 361313,
|
||||||
|
"ISR": 9053300,
|
||||||
|
"ITA": 60297396,
|
||||||
|
"JAM": 2948279,
|
||||||
|
"JEY": 107800,
|
||||||
|
"JOR": 10101694,
|
||||||
|
"JPN": 126264931,
|
||||||
|
"KAB": 36175,
|
||||||
|
"KAS": 6000,
|
||||||
|
"KAZ": 18513930,
|
||||||
|
"KEN": 52573973,
|
||||||
|
"KGZ": 6456900,
|
||||||
|
"KHM": 16486542,
|
||||||
|
"KIR": 117606,
|
||||||
|
"KNA": 52834,
|
||||||
|
"KOR": 51709098,
|
||||||
|
"KOS": 1794248,
|
||||||
|
"KWT": 4207083,
|
||||||
|
"LAO": 7169455,
|
||||||
|
"LBN": 6855713,
|
||||||
|
"LBR": 4937374,
|
||||||
|
"LBY": 6777452,
|
||||||
|
"LCA": 182790,
|
||||||
|
"LIE": 38019,
|
||||||
|
"LKA": 21803000,
|
||||||
|
"LSO": 2125268,
|
||||||
|
"LTU": 2786844,
|
||||||
|
"LUX": 619896,
|
||||||
|
"LVA": 1912789,
|
||||||
|
"MAC": 640445,
|
||||||
|
"MAF": 38002,
|
||||||
|
"MAR": 36471769,
|
||||||
|
"MCO": 38964,
|
||||||
|
"MDA": 2657637,
|
||||||
|
"MDG": 26969307,
|
||||||
|
"MDV": 530953,
|
||||||
|
"MEX": 127575529,
|
||||||
|
"MHL": 58791,
|
||||||
|
"MKD": 2083459,
|
||||||
|
"MLI": 19658031,
|
||||||
|
"MLT": 502653,
|
||||||
|
"MMR": 54045420,
|
||||||
|
"MNE": 622137,
|
||||||
|
"MNG": 3225167,
|
||||||
|
"MNP": 57216,
|
||||||
|
"MOZ": 30366036,
|
||||||
|
"MRT": 4525696,
|
||||||
|
"MSR": 4649,
|
||||||
|
"MUS": 1265711,
|
||||||
|
"MWI": 18628747,
|
||||||
|
"MYS": 31949777,
|
||||||
|
"NAM": 2494530,
|
||||||
|
"NCL": 287800,
|
||||||
|
"NER": 23310715,
|
||||||
|
"NFK": 2169,
|
||||||
|
"NGA": 200963599,
|
||||||
|
"NIC": 6545502,
|
||||||
|
"NIU": 1620,
|
||||||
|
"NLD": 17332850,
|
||||||
|
"NOR": 5347896,
|
||||||
|
"NPL": 28608710,
|
||||||
|
"NRU": 12581,
|
||||||
|
"NZL": 4917000,
|
||||||
|
"OMN": 4974986,
|
||||||
|
"PAK": 216565318,
|
||||||
|
"PAN": 4246439,
|
||||||
|
"PCN": 54,
|
||||||
|
"PER": 32510453,
|
||||||
|
"PGA": 100,
|
||||||
|
"PHL": 108116615,
|
||||||
|
"PLW": 18008,
|
||||||
|
"PNG": 8776109,
|
||||||
|
"POL": 37970874,
|
||||||
|
"PRI": 3193694,
|
||||||
|
"PRK": 25666161,
|
||||||
|
"PRT": 10269417,
|
||||||
|
"PRY": 7044636,
|
||||||
|
"PSX": 4685306,
|
||||||
|
"PYF": 279287,
|
||||||
|
"QAT": 2832067,
|
||||||
|
"ROU": 19356544,
|
||||||
|
"RUS": 144373535,
|
||||||
|
"RWA": 12626950,
|
||||||
|
"SAH": 603253,
|
||||||
|
"SAU": 34268528,
|
||||||
|
"SDN": 42813238,
|
||||||
|
"SDS": 11062113,
|
||||||
|
"SEN": 16296364,
|
||||||
|
"SGP": 5703569,
|
||||||
|
"SGS": 30,
|
||||||
|
"SHN": 4534,
|
||||||
|
"SLB": 669823,
|
||||||
|
"SLE": 7813215,
|
||||||
|
"SLV": 6453553,
|
||||||
|
"SMR": 33860,
|
||||||
|
"SOL": 5096159,
|
||||||
|
"SOM": 10192317,
|
||||||
|
"SPM": 5997,
|
||||||
|
"SRB": 6944975,
|
||||||
|
"STP": 215056,
|
||||||
|
"SUR": 581363,
|
||||||
|
"SVK": 5454073,
|
||||||
|
"SVN": 2087946,
|
||||||
|
"SWE": 10285453,
|
||||||
|
"SWZ": 1148130,
|
||||||
|
"SXM": 40733,
|
||||||
|
"SYC": 97625,
|
||||||
|
"SYR": 17070135,
|
||||||
|
"TCA": 38191,
|
||||||
|
"TCD": 15946876,
|
||||||
|
"TGO": 8082366,
|
||||||
|
"THA": 69625582,
|
||||||
|
"TJK": 9321018,
|
||||||
|
"TKM": 5942089,
|
||||||
|
"TLS": 1293119,
|
||||||
|
"TON": 104494,
|
||||||
|
"TTO": 1394973,
|
||||||
|
"TUN": 11694719,
|
||||||
|
"TUR": 83429615,
|
||||||
|
"TUV": 11646,
|
||||||
|
"TWN": 23568378,
|
||||||
|
"TZA": 58005463,
|
||||||
|
"UGA": 44269594,
|
||||||
|
"UKR": 44385155,
|
||||||
|
"UMI": 300,
|
||||||
|
"URY": 3461734,
|
||||||
|
"USA": 328239523,
|
||||||
|
"USG": 6000,
|
||||||
|
"UZB": 33580650,
|
||||||
|
"VAT": 825,
|
||||||
|
"VCT": 110589,
|
||||||
|
"VEN": 28515829,
|
||||||
|
"VGB": 30030,
|
||||||
|
"VIR": 106631,
|
||||||
|
"VNM": 96462106,
|
||||||
|
"VUT": 299882,
|
||||||
|
"WLF": 11558,
|
||||||
|
"WSB": 7850,
|
||||||
|
"WSM": 197097,
|
||||||
|
"YEM": 29161922,
|
||||||
|
"ZAF": 58558270,
|
||||||
|
"ZMB": 17861030,
|
||||||
|
"ZWE": 14645468
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-adjacency.mjs — build the precomputed region-adjacency
|
||||||
|
* manifest: `public/region-adjacency.json` = `{ "<regionKey>": ["<neighbourKey>", ...], ... }`.
|
||||||
|
*
|
||||||
|
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||||
|
* which *other* sub-regions it **borders** — i.e. shares an edge or a point with.
|
||||||
|
* The result is the data behind the model's `getNeighbours` tool (P2 of #16):
|
||||||
|
* given a set of region keys, the engine returns the union of their adjacency
|
||||||
|
* entries (excluding the queried keys), so the LLM can plan *frontier-biased,
|
||||||
|
* contiguous* territory transfers (a nation realistically gains/loses a region
|
||||||
|
* it already abuts) instead of whole countries.
|
||||||
|
*
|
||||||
|
* Method — bounding-box-pruned polygon intersection (turf is allowed in .mjs
|
||||||
|
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||||
|
* v0.17 note). For each unordered pair of regions (i < j) we first do a cheap
|
||||||
|
* bbox-proximity reject with a small gap tolerance (adjacent regions' bboxes
|
||||||
|
* touch, so their gap is ≈0), and only for the survivors do we run
|
||||||
|
* `@turf/boolean-intersects`, which returns true exactly when the two
|
||||||
|
* (Multi)Polygons share at least one point (edge or corner touch, or overlap).
|
||||||
|
* A hit adds each key to the other's set — adjacency is recorded **symmetrically**
|
||||||
|
* and **deduplicated** (a neighbour appears once even if the pair shares several
|
||||||
|
* disjoint border segments).
|
||||||
|
*
|
||||||
|
* Output contract (asserted by scripts/region-adjacency.test.ts):
|
||||||
|
* - one entry for **every** region key (including regions with no bordering
|
||||||
|
* neighbours, e.g. isolated islands — their list is simply empty);
|
||||||
|
* - adjacency is symmetric (A ∈ B's list ⟺ B ∈ A's list);
|
||||||
|
* - no key lists itself.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: the input files are fixed, the intersection test
|
||||||
|
* is a pure geometric computation, and the output is written with keys and
|
||||||
|
* neighbour lists both sorted — so a re-run rewrites a byte-identical file.
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-adjacency.mjs` (or `npm run build:region-adjacency`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import bbox from '@turf/bbox';
|
||||||
|
import booleanIntersects from '@turf/boolean-intersects';
|
||||||
|
import { feature } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
// "Close enough" bbox gap (degrees) for the cheap reject. Adjacent regions'
|
||||||
|
// bboxes touch (gap ≈ 0); a small tolerance absorbs 4-dp coordinate rounding
|
||||||
|
// and digitization fuzz. It only prunes work — it never affects correctness,
|
||||||
|
// because `booleanIntersects` is the final arbiter (a pair is a neighbour only
|
||||||
|
// if the polygons actually share a point).
|
||||||
|
const GAP_DEG = 0.05; // ≈ 5.5 km
|
||||||
|
|
||||||
|
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
||||||
|
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
||||||
|
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
||||||
|
|
||||||
|
// Region records in stable file order, keyed by their region key.
|
||||||
|
const regions = [];
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
regions.push({ key, feature: feature(f.geometry), bbox: bbox(f.geometry) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regions.length === 0) {
|
||||||
|
throw new Error('no regions with valid region keys — check regions.geo.json / region-keys.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// bboxes are flat [minX, minY, maxX, maxY] (turf's BBox type).
|
||||||
|
function bboxesClose(a, b) {
|
||||||
|
return (
|
||||||
|
a[0] <= b[2] + GAP_DEG && // a.w <= b.e + gap
|
||||||
|
b[0] <= a[2] + GAP_DEG && // b.w <= a.e + gap
|
||||||
|
a[1] <= b[3] + GAP_DEG && // a.s <= b.n + gap
|
||||||
|
b[1] <= a[3] + GAP_DEG // b.s <= a.n + gap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// key -> set of neighbouring keys (deduped; a pair sharing several segments is
|
||||||
|
// recorded once).
|
||||||
|
const adj = new Map();
|
||||||
|
for (const r of regions) adj.set(r.key, new Set());
|
||||||
|
|
||||||
|
let pairChecks = 0;
|
||||||
|
let intersections = 0;
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
for (let j = i + 1; j < regions.length; j++) {
|
||||||
|
if (!bboxesClose(regions[i].bbox, regions[j].bbox)) continue; // cheap reject
|
||||||
|
pairChecks++;
|
||||||
|
let touches = false;
|
||||||
|
try {
|
||||||
|
touches = booleanIntersects(regions[i].feature, regions[j].feature);
|
||||||
|
} catch {
|
||||||
|
touches = false; // a degenerate geometry pair is treated as non-adjacent
|
||||||
|
}
|
||||||
|
if (touches) {
|
||||||
|
intersections++;
|
||||||
|
adj.get(regions[i].key).add(regions[j].key);
|
||||||
|
adj.get(regions[j].key).add(regions[i].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently: keys sorted, neighbour lists sorted ------------------
|
||||||
|
// Every region key gets an entry (an isolated island's list is simply empty).
|
||||||
|
const manifest = {};
|
||||||
|
for (const key of [...adj.keys()].sort()) {
|
||||||
|
manifest[key] = [...adj.get(key)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-reference guard (can't happen — we only ever add the *other* region — but
|
||||||
|
// assert it so a future regression is loud rather than silent).
|
||||||
|
for (const [k, list] of Object.entries(manifest)) {
|
||||||
|
if (list.includes(k)) throw new Error(`region ${k} lists itself as a neighbour`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-adjacency.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
// --- summary ------------------------------------------------------------------
|
||||||
|
const totalEntries = Object.values(manifest).reduce((n, l) => n + l.length, 0);
|
||||||
|
const withNeighbours = Object.values(manifest).filter((l) => l.length > 0).length;
|
||||||
|
const maxDegree = Math.max(0, ...Object.values(manifest).map((l) => l.length));
|
||||||
|
console.log(
|
||||||
|
`[build-region-adjacency] wrote public/region-adjacency.json — ${Object.keys(manifest).length} region keys, ` +
|
||||||
|
`${totalEntries / 2} border pairs (${totalEntries} directed entries), max degree ${maxDegree}, ` +
|
||||||
|
`${withNeighbours} with ≥1 neighbour (${pairChecks} bbox-close pairs tested, ${intersections} intersect)`,
|
||||||
|
);
|
||||||
|
// A couple of human-checkable examples (highest-degree regions, stable order).
|
||||||
|
const byDegree = Object.entries(manifest).sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
|
||||||
|
for (const [k, list] of byDegree.slice(0, 3)) {
|
||||||
|
console.log(` e.g. ${k} (deg ${list.length}) → ${list.slice(0, 4).join(', ')}${list.length > 4 ? ', …' : ''}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-centroids.mjs — build the precomputed region-centroid
|
||||||
|
* manifest: `public/region-centroids.json` = `{ "<regionKey>": { "lat": <number>, "lon": <number>, "spread": <number> }, ... }`.
|
||||||
|
*
|
||||||
|
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||||
|
* the point the LLM's map markers (v0.27, #44) will be anchored to: the
|
||||||
|
* **centroid of the region's largest-area part** (a MultiPolygon's biggest
|
||||||
|
* island/lake-piece rather than an area-weighted average of all parts), plus
|
||||||
|
* a `spread` value ≈ 0.1 × the smallest bbox dimension of that part (in
|
||||||
|
* degrees) — the engine scatters markers of one region within ±(spread/2)
|
||||||
|
* around the centroid using a deterministic per-marker offset, so several
|
||||||
|
* markers of the same region do not stack on one pixel.
|
||||||
|
*
|
||||||
|
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||||||
|
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||||
|
* v0.17 note). For each feature we (1) split a MultiPolygon into its parts
|
||||||
|
* (a plain Polygon is its own single part), (2) pick the part with the
|
||||||
|
* greatest `@turf/area`, (3) take `@turf/centroid` of that part and
|
||||||
|
* `@turf/bbox` of that part, and (4) round everything to 6 decimal places
|
||||||
|
* (≈ 0.1 m at this scale — far below the map's visual precision).
|
||||||
|
*
|
||||||
|
* Output contract (asserted by scripts/region-centroids.test.ts):
|
||||||
|
* - one entry for **every** region key, keyed identically to
|
||||||
|
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||||
|
* - each centroid lies inside its region's (largest part's) bbox;
|
||||||
|
* - `spread` is a non-negative number of degrees (0 for degenerate parts);
|
||||||
|
* - values are rounded to at most 6 decimal places.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: the input files are fixed, turf is a pure
|
||||||
|
* geometric computation, the output keeps the geo file's (stable) feature
|
||||||
|
* order and rounds to fixed precision — so a re-run rewrites a byte-identical
|
||||||
|
* file (the test re-runs this script and compares bytes).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-centroids.mjs` (or `npm run build:region-centroids`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import area from '@turf/area';
|
||||||
|
import bbox from '@turf/bbox';
|
||||||
|
import centroid from '@turf/centroid';
|
||||||
|
import { feature } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||||
|
|
||||||
|
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
||||||
|
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
||||||
|
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The polygon parts of a geometry: a MultiPolygon's individual polygons, a
|
||||||
|
* plain Polygon as its own single part, anything else as an empty list (the
|
||||||
|
* caller falls back to the whole-feature centroid).
|
||||||
|
*/
|
||||||
|
function partsOf(geometry) {
|
||||||
|
if (geometry?.type === 'MultiPolygon') return geometry.coordinates ?? [];
|
||||||
|
if (geometry?.type === 'Polygon') return [geometry.coordinates];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {};
|
||||||
|
let skipped = 0;
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
const parts = partsOf(f.geometry);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
// Degenerate geometry (a point/line region): anchor at the feature's own
|
||||||
|
// centroid. Can't happen with the current 1,800-polygon dataset, but the
|
||||||
|
// fallback keeps the manifest complete if the generator ever changes.
|
||||||
|
skipped += 1;
|
||||||
|
const c = centroid(feature(f.geometry));
|
||||||
|
const b = bbox(feature(f.geometry));
|
||||||
|
manifest[key] = {
|
||||||
|
lat: round6(c.geometry.coordinates[1]),
|
||||||
|
lon: round6(c.geometry.coordinates[0]),
|
||||||
|
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Pick the part with the greatest area (km²) and anchor to IT, so a
|
||||||
|
// multi-part region's markers cluster on its main mass, not between parts.
|
||||||
|
let best = 0;
|
||||||
|
let bestArea = -1;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const a = area(feature({ type: 'Polygon', coordinates: parts[i] }));
|
||||||
|
if (a > bestArea) {
|
||||||
|
bestArea = a;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const part = feature({ type: 'Polygon', coordinates: parts[best] });
|
||||||
|
const c = centroid(part);
|
||||||
|
const b = bbox(part);
|
||||||
|
manifest[key] = {
|
||||||
|
lat: round6(c.geometry.coordinates[1]),
|
||||||
|
lon: round6(c.geometry.coordinates[0]),
|
||||||
|
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ---
|
||||||
|
const expectedKeys = Object.values(idToKey);
|
||||||
|
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${expectedKeys.length} centroid entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const k of expectedKeys) {
|
||||||
|
if (!(k in manifest)) throw new Error(`missing centroid for region key ${JSON.stringify(k)}`);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(manifest)) {
|
||||||
|
if (!Number.isFinite(v.lat) || !Number.isFinite(v.lon) || !Number.isFinite(v.spread)) {
|
||||||
|
throw new Error(`non-finite centroid for ${k}: ${JSON.stringify(v)}`);
|
||||||
|
}
|
||||||
|
if (v.spread < 0) throw new Error(`negative spread for ${k}: ${v.spread}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (stable order = insertion order = geo file order) ---
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-centroids.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
// --- summary ------------------------------------------------------------------
|
||||||
|
const spreads = Object.values(manifest).map((v) => v.spread);
|
||||||
|
console.log(
|
||||||
|
`[build-region-centroids] wrote public/region-centroids.json — ${Object.keys(manifest).length} region keys` +
|
||||||
|
(skipped ? ` (${skipped} non-polygon fallbacks)` : '') +
|
||||||
|
`, spread min/max ${Math.min(...spreads)} / ${Math.max(...spreads)} deg`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-geo.mjs — build the region **geo** manifest:
|
||||||
|
* `public/region-geo.json` = `{ "<regionKey>": { "area": <number>, "population": <number> }, ... }`.
|
||||||
|
*
|
||||||
|
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||||
|
* two facts the v0.28 per-region stats model (and its area×population weighting)
|
||||||
|
* needs:
|
||||||
|
* - `area` — the region's **total** surface area in km², from
|
||||||
|
* `@turf/area` on the FINAL generated feature
|
||||||
|
* (`public/regions.geo.json`) — turf reports square metres,
|
||||||
|
* so the raw value is divided by 1e6 — rounded to 2 decimals.
|
||||||
|
* - `population` — the region's share of its **parent country's** real
|
||||||
|
* population (Natural Earth 1:10m admin-0 `POP_EST`, from the
|
||||||
|
* committed `public/ne-admin0-pop.json`), distributed across
|
||||||
|
* the country's sub-regions **proportional to each region's
|
||||||
|
* area** (a first-order "uniform population density within a
|
||||||
|
* country" approximation — Natural Earth admin-1, which the
|
||||||
|
* region generator uses, carries no per-unit population).
|
||||||
|
*
|
||||||
|
* Population distribution uses **largest remainder** per country, so the sum of
|
||||||
|
* a country's sub-region populations is EXACTLY the country's POP_EST, and the
|
||||||
|
* sum over all 1,800 regions is EXACTLY the sum of the 177 repo countries'
|
||||||
|
* POP_EST (7,654,092,021) — no rounding drift.
|
||||||
|
*
|
||||||
|
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||||||
|
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||||
|
* v0.17 note). The population source is the COMMITTED `public/ne-admin0-pop.json`
|
||||||
|
* (a small derived table produced once by `scripts/extract-admin0-pop.mjs` from
|
||||||
|
* the 13 MB Natural Earth admin-0 file, which we do NOT commit) — so this build
|
||||||
|
* is fully reproducible in CI.
|
||||||
|
*
|
||||||
|
* Output contract (asserted by scripts/region-geo.test.ts):
|
||||||
|
* - one entry for **every** region key, keyed identically to
|
||||||
|
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||||
|
* - `area` is a finite number >= 0 (km², <= 2 decimals);
|
||||||
|
* - `population` is a non-negative integer;
|
||||||
|
* - the total population across all entries is a non-negative integer.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: fixed inputs (the geo file, the key manifest, and
|
||||||
|
* the committed population table), pure turf, largest-remainder with a
|
||||||
|
* (remainder desc, key asc) tie-break, and stable sorted key order → a re-run
|
||||||
|
* rewrites a byte-identical file (the test re-runs this script and compares
|
||||||
|
* bytes).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-geo.mjs` (or `npm run build:region-geo`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import area from '@turf/area';
|
||||||
|
import { feature } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
const round2 = (v) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
// --- load the inputs (plain parse) ------------------------------------------
|
||||||
|
const geoPath = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const keysPath = path.join(root, 'public', 'region-keys.json');
|
||||||
|
const popPath = path.join(root, 'public', 'ne-admin0-pop.json');
|
||||||
|
|
||||||
|
let geo, idToKey, popByCountry;
|
||||||
|
try {
|
||||||
|
geo = JSON.parse(readFileSync(geoPath, 'utf8'));
|
||||||
|
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
|
||||||
|
popByCountry = JSON.parse(readFileSync(popPath, 'utf8'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[build-region-geo] could not load the inputs (${geoPath}, ${keysPath}, ${popPath}). ` +
|
||||||
|
`Is public/ne-admin0-pop.json present? Regenerate it with scripts/extract-admin0-pop.mjs.`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass over the geo features: for each region key record its (unrounded)
|
||||||
|
* turf area and its parent ISO_A3, then group the keys by parent so the
|
||||||
|
* population can be distributed per country.
|
||||||
|
*/
|
||||||
|
const regionArea = new Map(); // key -> unrounded area (km²)
|
||||||
|
const regionParent = new Map(); // key -> parent ISO_A3
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
// @turf/area reports square METRES; the manifest stores km².
|
||||||
|
const a = area(feature(f.geometry)) / 1e6;
|
||||||
|
regionArea.set(key, a);
|
||||||
|
regionParent.set(key, typeof f.properties?.parent === 'string' ? f.properties.parent : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group region keys by parent country (file order is stable, but we sort for
|
||||||
|
// deterministic population distribution).
|
||||||
|
const keysByCountry = new Map(); // parent -> string[] of keys
|
||||||
|
for (const [key, parent] of regionParent) {
|
||||||
|
if (!parent) continue; // a region with no parent has no population source
|
||||||
|
const list = keysByCountry.get(parent);
|
||||||
|
if (list) list.push(key);
|
||||||
|
else keysByCountry.set(parent, [key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const population = new Map(); // key -> integer population
|
||||||
|
for (const [parent, keys] of keysByCountry) {
|
||||||
|
keys.sort(); // deterministic ordering for the tie-break
|
||||||
|
const P = Number.isFinite(popByCountry[parent]) ? Math.max(0, popByCountry[parent]) : 0;
|
||||||
|
const totalArea = keys.reduce((s, k) => s + (regionArea.get(k) || 0), 0);
|
||||||
|
if (P <= 0 || totalArea <= 0) {
|
||||||
|
for (const k of keys) population.set(k, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Largest remainder: floor each share, then hand out the leftover 1s to the
|
||||||
|
// regions with the largest fractional remainders (ties broken by key asc).
|
||||||
|
const shares = keys.map((k) => {
|
||||||
|
const a = regionArea.get(k) || 0;
|
||||||
|
const ideal = (P * a) / totalArea;
|
||||||
|
return { k, floor: Math.floor(ideal), rem: ideal - Math.floor(ideal) };
|
||||||
|
});
|
||||||
|
const distributed = shares.reduce((s, x) => s + x.floor, 0);
|
||||||
|
let deficit = P - distributed; // 0 <= deficit < keys.length
|
||||||
|
const order = shares
|
||||||
|
.map((x, i) => ({ i, rem: x.rem, k: x.k }))
|
||||||
|
.sort((a, b) => (b.rem - a.rem) || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0));
|
||||||
|
for (let i = 0; i < order.length && deficit > 0; i++) {
|
||||||
|
shares[order[i].i].floor += 1;
|
||||||
|
deficit -= 1;
|
||||||
|
}
|
||||||
|
for (const x of shares) population.set(x.k, x.floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- assemble the manifest (sorted keys) -------------------------------------
|
||||||
|
const expectedKeys = Object.values(idToKey);
|
||||||
|
const manifest = {};
|
||||||
|
for (const k of [...expectedKeys].sort()) {
|
||||||
|
const a = regionArea.get(k);
|
||||||
|
const p = population.get(k);
|
||||||
|
if (a === undefined || p === undefined) {
|
||||||
|
throw new Error(`region key ${JSON.stringify(k)} has no computed area/population`);
|
||||||
|
}
|
||||||
|
manifest[k] = { area: round2(a), population: p };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ----
|
||||||
|
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${expectedKeys.length} region-geo entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let totalPop = 0;
|
||||||
|
let zeros = 0;
|
||||||
|
for (const [k, v] of Object.entries(manifest)) {
|
||||||
|
if (!Number.isFinite(v.area) || v.area < 0) throw new Error(`bad area for ${k}: ${JSON.stringify(v)}`);
|
||||||
|
if (!Number.isInteger(v.population) || v.population < 0) throw new Error(`bad population for ${k}: ${JSON.stringify(v)}`);
|
||||||
|
if (v.area === 0) zeros += 1;
|
||||||
|
totalPop += v.population;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-geo.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
// --- summary ------------------------------------------------------------------
|
||||||
|
console.log(
|
||||||
|
`[build-region-geo] wrote public/region-geo.json — ${Object.keys(manifest).length} region keys` +
|
||||||
|
`${zeros ? ` (${zeros} zero-area regions)` : ''}, total population ${totalPop.toLocaleString('en-US')}`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-keys.mjs — build the deterministic region-key manifest.
|
||||||
|
*
|
||||||
|
* Region ownership flips from whole-country (ISO_A3) to individual sub-region
|
||||||
|
* granularity, but the LLM and the persisted `Nation.territory` list need a
|
||||||
|
* *stable, human-readable, globally unique* identifier per sub-region. Raw
|
||||||
|
* feature ids (`FR-57`) are opaque to the model, so we mint a readable key of
|
||||||
|
* the form:
|
||||||
|
*
|
||||||
|
* <name-slug>-<4char> e.g. brittany-xk2a, western-alun
|
||||||
|
*
|
||||||
|
* where:
|
||||||
|
* - <name-slug> = properties.name → NFKD → strip combining marks → lowercase
|
||||||
|
* → a tiny fold table for the handful of letters NFKD does NOT decompose
|
||||||
|
* (ə ø ð đ œ ł, the only non-folding letters present in this dataset)
|
||||||
|
* → every non-alphanumeric run collapsed to a single '-' → leading/trailing
|
||||||
|
* '-' trimmed.
|
||||||
|
* - <4char> = a deterministic 4-letter lowercase code derived from the
|
||||||
|
* feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable
|
||||||
|
* (same id → same code on every run) because the key is persisted in
|
||||||
|
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
||||||
|
* regions all named "Western") the 4-char codes are made distinct by
|
||||||
|
* re-deriving with a deterministic salt on collision, which guarantees the
|
||||||
|
* full key is unique across all 1,800 regions.
|
||||||
|
*
|
||||||
|
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
||||||
|
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
||||||
|
* is looked up by the client (`WorldMap`) and the server (`map`/`presets`/
|
||||||
|
* `engine`) to join a sub-region's stable id to its readable key.
|
||||||
|
*
|
||||||
|
* Builtins only — deliberately NO `@turf`/geojson import (importing one pulls
|
||||||
|
* `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the
|
||||||
|
* v0.17 changelog note). Reads the geo file with plain `JSON.parse`.
|
||||||
|
*
|
||||||
|
* Idempotent: a re-run rewrites a byte-identical file (no timestamps, no
|
||||||
|
* randomness, stable feature order, deterministic hash).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`).
|
||||||
|
*/
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letters present in the dataset that NFKD does NOT decompose, and their
|
||||||
|
* single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup.
|
||||||
|
* Kept deliberately tiny + deterministic (only the six that actually occur).
|
||||||
|
*/
|
||||||
|
const FOLD = {
|
||||||
|
'ə': 'a', // schwa
|
||||||
|
'ø': 'o', // o-stroke
|
||||||
|
'ð': 'd', // eth
|
||||||
|
'đ': 'dj', // d-stroke
|
||||||
|
'œ': 'oe', // oe ligature
|
||||||
|
'ł': 'l', // l-stroke
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readable slug from a region name. NFKD decomposes + we strip the combining
|
||||||
|
* marks, lowercase, fold the non-decomposing letters, collapse every other
|
||||||
|
* non-alphanumeric run to one hyphen, and trim leading/trailing hyphens.
|
||||||
|
*/
|
||||||
|
function slug(name) {
|
||||||
|
let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
s = s.toLowerCase();
|
||||||
|
s = [...s].map((ch) => FOLD[ch] ?? ch).join('');
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
s = s.replace(/^-+|-+$/g, '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit — a tiny, stable, pure string hash. */
|
||||||
|
function fnv1a32(str) {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = (h * 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
|
||||||
|
function code4(h) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 4-char code for an id, deterministically salted on within-group collision. */
|
||||||
|
function codeFor(id, used) {
|
||||||
|
let code = code4(fnv1a32(id));
|
||||||
|
let attempt = 0;
|
||||||
|
while (used.has(code)) {
|
||||||
|
attempt += 1;
|
||||||
|
code = code4(fnv1a32(`${id}#${attempt}`));
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load the region geo (plain parse; no geojson types) ---
|
||||||
|
const geoFile = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
||||||
|
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||||
|
|
||||||
|
if (features.length !== 1800) {
|
||||||
|
throw new Error(`expected 1,800 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group features by their (computed) name-slug, preserving file order, so the
|
||||||
|
// within-slug 4-char uniqueness resolution below is deterministic.
|
||||||
|
const groups = new Map(); // slug -> array of { id }
|
||||||
|
for (const f of features) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (!id || typeof name !== 'string') {
|
||||||
|
throw new Error(`region with id ${JSON.stringify(id)} has no usable name`);
|
||||||
|
}
|
||||||
|
const s = slug(name);
|
||||||
|
if (!groups.has(s)) groups.set(s, []);
|
||||||
|
groups.get(s).push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign keys, resolving 4-char collisions within each slug group.
|
||||||
|
const manifest = {};
|
||||||
|
for (const [s, ids] of groups) {
|
||||||
|
const used = new Set();
|
||||||
|
for (const { id } of ids) {
|
||||||
|
manifest[id] = `${s}-${codeFor(id, used)}`;
|
||||||
|
used.add(manifest[id].slice(-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ---
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
if (new Set(keys).size !== keys.length) {
|
||||||
|
throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`);
|
||||||
|
}
|
||||||
|
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (!KEY_RE.test(k) || !k.includes('-')) {
|
||||||
|
throw new Error(`malformed region key: ${JSON.stringify(k)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (stable order = insertion order = file order) ---
|
||||||
|
const out = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
|
||||||
|
|
||||||
|
const western = Object.entries(manifest)
|
||||||
|
.filter(([, k]) => k.startsWith('western-'))
|
||||||
|
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||||
|
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,800 expected)`);
|
||||||
|
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-resources.mjs — build the region **resources** manifest:
|
||||||
|
* `public/region-resources.json` = `{ "<regionKey>": { "oil": <0-1>, "iron": <0-1>, "food": <0-1>, "industry": <0-1> }, ... }`.
|
||||||
|
*
|
||||||
|
* Every sub-region (region key, see `public/region-keys.json`) gets a
|
||||||
|
* synthesized four-value resource profile. For each resource dimension a
|
||||||
|
* value in [0, 1] (2 decimal places) is derived from the FNV-1a 32-bit hash
|
||||||
|
* of the region key (salted per resource — the same tiny, stable, pure
|
||||||
|
* string hash the region keys themselves use), then scaled MODESTLY by the
|
||||||
|
* region's area (from the committed `public/region-geo.json`, when present):
|
||||||
|
* larger regions carry a slightly higher value (a log-scaled factor of at
|
||||||
|
* most ~1.2), so a big region reads as holding more of each resource. The
|
||||||
|
* geo file is OPTIONAL — without it (or for a zero-area region) the factor
|
||||||
|
* is exactly 1 and the profile is pure hash.
|
||||||
|
*
|
||||||
|
* This is the v0.36 (#59) "Resources & Industry" data source: the engine
|
||||||
|
* scales each region's per-turn industry boost by its `industry` value, and
|
||||||
|
* the LLM prompt carries each nation's aggregate profile (the
|
||||||
|
* area×population-weighted mean of the owned regions' profiles, see
|
||||||
|
* `deriveNationResources` in shared/protocol.ts). The profiles are
|
||||||
|
* synthesized (there is no real per-sub-region resource census) — they exist
|
||||||
|
* to give the model a stable, deterministic world fact to reason about.
|
||||||
|
*
|
||||||
|
* Output contract (asserted by scripts/build-region-resources.test.ts):
|
||||||
|
* - one entry for **every** region key, keyed identically to
|
||||||
|
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||||
|
* - each entry is `{ oil, iron, food, industry }`, every value a finite
|
||||||
|
* number in [0, 1] with at most 2 decimal places;
|
||||||
|
* - keys are in sorted (deterministic) order.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: fixed inputs (the key manifest, and the
|
||||||
|
* committed geo file when present), pure string hashing, fixed 2-dp
|
||||||
|
* rounding, and stable sorted key order → a re-run rewrites a byte-identical
|
||||||
|
* file (the test re-runs this script and compares bytes).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-resources.mjs` (or `npm run build:region-resources`).
|
||||||
|
*/
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
const round2 = (v) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit — the same tiny, stable, pure hash the region keys use. */
|
||||||
|
function fnv1a32(str) {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCES = ['oil', 'iron', 'food', 'industry'];
|
||||||
|
|
||||||
|
// --- load the inputs (plain parse) ------------------------------------------
|
||||||
|
const keysPath = path.join(root, 'public', 'region-keys.json');
|
||||||
|
const geoPath = path.join(root, 'public', 'region-geo.json');
|
||||||
|
|
||||||
|
let idToKey;
|
||||||
|
try {
|
||||||
|
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[build-region-resources] could not load ${keysPath}.`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// region-geo is OPTIONAL: a missing/broken file means "no area scaling"
|
||||||
|
// (factor 1 for every region) — the profile is then pure hash.
|
||||||
|
let areaByKey = {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(geoPath, 'utf8'));
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
if (v && typeof v.area === 'number' && Number.isFinite(v.area) && v.area >= 0) {
|
||||||
|
areaByKey[k] = v.area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
areaByKey = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base value in [0, 1) for one (region key, resource): the low 14 bits of
|
||||||
|
* the salted FNV-1a hash, normalized. Stable across runs and platforms; a
|
||||||
|
* uniform-enough spread for a synthesized profile.
|
||||||
|
*/
|
||||||
|
const base = (key, resource) => (fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The area scaling factor: 1.0 for a region with no geo entry (or zero area),
|
||||||
|
* rising on a log scale (clamped) to at most ~1.2 for the largest regions —
|
||||||
|
* bigger regions hold proportionally more of each resource.
|
||||||
|
*/
|
||||||
|
const areaScale = (key) => {
|
||||||
|
const a = areaByKey[key];
|
||||||
|
if (!a || a <= 0) return 1;
|
||||||
|
// log10(1 + area) runs ~0.004 (tiny slivers) to ~5.5 (a 300k km² region);
|
||||||
|
// /8 + clamp keeps the factor in [1, 1.2].
|
||||||
|
const t = Math.min(1, Math.log10(1 + a) / 8);
|
||||||
|
return 1 + 0.2 * t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- assemble the manifest (sorted keys) -------------------------------------
|
||||||
|
const expectedKeys = Object.values(idToKey);
|
||||||
|
const manifest = {};
|
||||||
|
for (const k of [...expectedKeys].sort()) {
|
||||||
|
const scale = areaScale(k);
|
||||||
|
manifest[k] = {
|
||||||
|
oil: round2(Math.min(1, base(k, 'oil') * scale)),
|
||||||
|
iron: round2(Math.min(1, base(k, 'iron') * scale)),
|
||||||
|
food: round2(Math.min(1, base(k, 'food') * scale)),
|
||||||
|
industry: round2(Math.min(1, base(k, 'industry') * scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ----
|
||||||
|
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${expectedKeys.length} region-resources entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let scaled = 0;
|
||||||
|
for (const [k, v] of Object.entries(manifest)) {
|
||||||
|
if (areaByKey[k]) scaled += 1;
|
||||||
|
for (const r of RESOURCES) {
|
||||||
|
const val = v[r];
|
||||||
|
if (typeof val !== 'number' || !Number.isFinite(val) || val < 0 || val > 1) {
|
||||||
|
throw new Error(`bad ${r} value for ${k}: ${JSON.stringify(val)}`);
|
||||||
|
}
|
||||||
|
if (Math.round(val * 100) / 100 !== val) {
|
||||||
|
throw new Error(`${r} over 2dp for ${k}: ${val}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
|
||||||
|
writeFileSync(
|
||||||
|
path.join(root, 'public', 'region-resources.json'),
|
||||||
|
JSON.stringify(manifest, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- summary ------------------------------------------------------------------
|
||||||
|
console.log(
|
||||||
|
`[build-region-resources] wrote public/region-resources.json — ${Object.keys(manifest).length} region keys` +
|
||||||
|
` (${scaled} area-scaled, ${Object.keys(manifest).length - scaled} pure-hash)`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-resources.test.ts — validation for the committed
|
||||||
|
* `public/region-resources.json` (built by `scripts/build-region-resources.mjs`).
|
||||||
|
*
|
||||||
|
* The manifest maps each region key to a synthesized four-value resource
|
||||||
|
* profile `{ oil, iron, food, industry }` — each a finite number in [0, 1]
|
||||||
|
* (2 decimal places). It is the v0.36 (#59) "Resources & Industry" data
|
||||||
|
* source: the engine scales each region's per-turn industry boost by its
|
||||||
|
* `industry` value, and the LLM prompt carries each nation's aggregate
|
||||||
|
* profile (the area×population-weighted mean of the owned regions' profiles,
|
||||||
|
* `deriveNationResources` in shared/protocol.ts).
|
||||||
|
*
|
||||||
|
* Like the region-geo manifest, this is a load-bearing data artifact — the
|
||||||
|
* test pins its contract. It is dependency-free: it reads the JSON with plain
|
||||||
|
* `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull `@types/geojson`
|
||||||
|
* into the tsc program and break `WorldMap.tsx` — see the v0.17 note). The
|
||||||
|
* determinism test re-runs the (turf-free) `.mjs` builder and compares bytes,
|
||||||
|
* matching that the builder is idempotent.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceEntry {
|
||||||
|
oil: number;
|
||||||
|
iron: number;
|
||||||
|
food: number;
|
||||||
|
industry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCES = ['oil', 'iron', 'food', 'industry'] as const;
|
||||||
|
|
||||||
|
// feature id -> region key
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const resources = readJson('public/region-resources.json') as Record<string, ResourceEntry>;
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
|
||||||
|
describe('region-resources.json', () => {
|
||||||
|
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||||
|
expect(new Set(Object.keys(resources))).toEqual(new Set(keys));
|
||||||
|
expect(Object.keys(resources)).toHaveLength(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is keyed in sorted (deterministic) order', () => {
|
||||||
|
const ks = Object.keys(resources);
|
||||||
|
expect(ks).toEqual([...ks].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every entry is a finite { oil, iron, food, industry } in [0,1] with ≤ 2 dp', () => {
|
||||||
|
for (const [k, v] of Object.entries(resources)) {
|
||||||
|
for (const r of RESOURCES) {
|
||||||
|
const val = v[r];
|
||||||
|
expect(typeof val, `non-number ${r} for ${k}`).toBe('number');
|
||||||
|
expect(Number.isFinite(val), `non-finite ${r} for ${k}`).toBe(true);
|
||||||
|
expect(val >= 0, `negative ${r} for ${k}: ${val}`).toBe(true);
|
||||||
|
expect(val <= 1, `${r} above 1 for ${k}: ${val}`).toBe(true);
|
||||||
|
expect(
|
||||||
|
Math.round(val * 100) / 100 === val,
|
||||||
|
`${r} over 2dp for ${k}: ${val}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spot-checks well-known regions for exact profiles', () => {
|
||||||
|
const is = (k: string, oil: number, iron: number, food: number, industry: number) => {
|
||||||
|
const v = resources[k];
|
||||||
|
expect(v, `missing resources for ${k}`).toBeDefined();
|
||||||
|
expect(v!.oil, `oil for ${k}`).toBe(oil);
|
||||||
|
expect(v!.iron, `iron for ${k}`).toBe(iron);
|
||||||
|
expect(v!.food, `food for ${k}`).toBe(food);
|
||||||
|
expect(v!.industry, `industry for ${k}`).toBe(industry);
|
||||||
|
};
|
||||||
|
is('california-kikh', 0.31, 0.1, 0.82, 0.97); // US-CA
|
||||||
|
is('arkhangel-sk-lhbk', 0.49, 0.52, 0.98, 0.82); // RU-ARK mainland core
|
||||||
|
is('pechora-qwhs', 0.11, 0.16, 1, 0.04); // RU-PEO (Arctic-coast band, #41)
|
||||||
|
is('yamal-gosw', 0.64, 1, 1, 0.49); // RU-YAM (Arctic-coast band, #41)
|
||||||
|
is('new-earth-eikt', 0.6, 0.56, 0.91, 0.66); // RU-ARK-NE (island group, #31)
|
||||||
|
is('severnaya-zemlya-awqp', 0.1, 0.48, 1, 0.3); // RU-ARK-SZ (island group, #31)
|
||||||
|
is('ain-lqtt', 0.94, 0.92, 0.51, 0.4); // FR-01 (Ain)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||||
|
const file = path.join(root, 'public', 'region-resources.json');
|
||||||
|
const before = readFileSync(file, 'utf8');
|
||||||
|
// The builder is cwd-independent (it resolves the repo root from its own
|
||||||
|
// location) and idempotent: same input files (region-keys.json + the
|
||||||
|
// committed region-geo.json when present) + pure string hashing + fixed
|
||||||
|
// 2-dp rounding + stable (sorted) key order → identical bytes.
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-resources.mjs')], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const after = readFileSync(file, 'utf8');
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* scripts/extract-admin0-pop.mjs — one-time extraction of the population
|
||||||
|
* SOURCE for the region-geo manifest: `public/ne-admin0-pop.json`.
|
||||||
|
*
|
||||||
|
* Output = `{ "<ADM0_A3>": <POP_EST>, ... }` — the estimated population
|
||||||
|
* (POP_EST) of every Natural Earth 1:10m **admin-0 country** code, keeping
|
||||||
|
* the **maximum** POP_EST seen for each code (Natural Earth ships a few
|
||||||
|
* duplicated / broken-out features per code, e.g. the `TLC` stubs, whose
|
||||||
|
* POP_EST is 0; the real country always wins by having the larger value).
|
||||||
|
* Only codes with a finite, positive POP_EST are recorded (a 0/null POP_EST
|
||||||
|
* has nothing to distribute). Keys are sorted; the file is written with
|
||||||
|
* 2-space indent + trailing newline (deterministic, committed).
|
||||||
|
*
|
||||||
|
* Why a separate committed file: `public/region-geo.json` (built by
|
||||||
|
* `build-region-geo.mjs`) needs a per-country population, but the raw
|
||||||
|
* Natural Earth GeoJSON (13 MB) is a *source* file we deliberately do NOT
|
||||||
|
* commit (see the AGENTS.md data-pipeline note). This script distils it
|
||||||
|
* into a tiny (a few KB) derived table that IS committed, so the region-geo
|
||||||
|
* builder stays fully reproducible in CI without the source present.
|
||||||
|
* Re-run only when the underlying Natural Earth data changes.
|
||||||
|
*
|
||||||
|
* The 1:10m admin-1 file (the one the region generator uses) carries NO
|
||||||
|
* population; the 1:10m admin-0 file does (uppercase `POP_EST` / `ADM0_A3`).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* NE_ADMIN0=/path/to/ne_10m_admin_0_countries.geojson node scripts/extract-admin0-pop.mjs
|
||||||
|
* (default source: /tmp/ne10m_admin0.geojson)
|
||||||
|
*/
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
const src = process.env.NE_ADMIN0 || '/tmp/ne10m_admin0.geojson';
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(src, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[extract-admin0-pop] could not read the Natural Earth admin-0 source at ${src}. ` +
|
||||||
|
`Download it (ne_10m_admin_0_countries.geojson) and re-run with NE_ADMIN0 pointing at it.`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = JSON.parse(raw);
|
||||||
|
|
||||||
|
/** ADM0_A3 -> max finite, positive POP_EST. */
|
||||||
|
const byCode = new Map();
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const p = f.properties ?? {};
|
||||||
|
const code = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : '';
|
||||||
|
const pop = typeof p.POP_EST === 'number' ? p.POP_EST : NaN;
|
||||||
|
if (!code || !Number.isFinite(pop) || pop <= 0) continue;
|
||||||
|
const prev = byCode.get(code);
|
||||||
|
if (prev === undefined || pop > prev) byCode.set(code, Math.round(pop));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byCode.size === 0) {
|
||||||
|
throw new Error('[extract-admin0-pop] no finite positive POP_EST found in the source — wrong file?');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic: sorted keys, 2-space indent, trailing newline.
|
||||||
|
const keys = [...byCode.keys()].sort();
|
||||||
|
const manifest = {};
|
||||||
|
for (const k of keys) manifest[k] = byCode.get(k);
|
||||||
|
|
||||||
|
const out = path.join(root, 'public', 'ne-admin0-pop.json');
|
||||||
|
writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (const v of byCode.values()) sum += v;
|
||||||
|
console.log(
|
||||||
|
`[extract-admin0-pop] wrote public/ne-admin0-pop.json — ${byCode.size} country codes, ` +
|
||||||
|
`total population ${sum.toLocaleString('en-US')} (Natural Earth 1:10m admin-0, max POP_EST per code)`,
|
||||||
|
);
|
||||||
@@ -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}`);
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-adjacency.test.ts — validation for the committed
|
||||||
|
* `public/region-adjacency.json` (built by `scripts/build-region-adjacency.mjs`).
|
||||||
|
*
|
||||||
|
* The manifest maps each region key to the list of region keys it **borders**
|
||||||
|
* (shares an edge/point with). It is the data behind the model's `getNeighbours`
|
||||||
|
* tool (v0.21, #16 P2), so it is a load-bearing data artifact — this test pins
|
||||||
|
* its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
|
||||||
|
* and never imports `@turf`/geojson types (importing one pulls `@types/geojson`
|
||||||
|
* into the tsc program and breaks `WorldMap.tsx`; see the v0.17 changelog note).
|
||||||
|
* Mirrors the style of `scripts/region-keys.test.ts`.
|
||||||
|
*/
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-adjacency.json', () => {
|
||||||
|
const adjacency = readJson('public/region-adjacency.json') as Record<string, string[]>;
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const keys = new Set(Object.values(manifest));
|
||||||
|
|
||||||
|
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||||
|
const manifestKeys = new Set(Object.values(manifest));
|
||||||
|
const adjacencyKeys = new Set(Object.keys(adjacency));
|
||||||
|
expect(adjacencyKeys).toEqual(manifestKeys);
|
||||||
|
expect(Object.keys(adjacency)).toHaveLength(keys.size);
|
||||||
|
expect(adjacencyKeys.size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every neighbour is itself a valid region key (referential integrity)', () => {
|
||||||
|
for (const [key, neighbours] of Object.entries(adjacency)) {
|
||||||
|
for (const n of neighbours) {
|
||||||
|
expect(keys.has(n), `neighbour ${n} of ${key} is not a valid region key`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no region lists itself as a neighbour', () => {
|
||||||
|
for (const [key, neighbours] of Object.entries(adjacency)) {
|
||||||
|
expect(neighbours.includes(key), `${key} lists itself`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacency is symmetric (A ∈ B ⟺ B ∈ A)', () => {
|
||||||
|
let checked = 0;
|
||||||
|
for (const [a, aList] of Object.entries(adjacency)) {
|
||||||
|
const aSet = new Set(aList);
|
||||||
|
for (const b of aList) {
|
||||||
|
const bList = adjacency[b];
|
||||||
|
expect(Array.isArray(bList), `${b} has no adjacency entry`).toBe(true);
|
||||||
|
// A lists B, so B must list A.
|
||||||
|
expect(bList!.includes(a), `${a} → ${b} but ${b} does not list ${a}`).toBe(true);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
// And the converse: every neighbour B of A must be a key (implied above) —
|
||||||
|
// also assert no *missing* reverse entry by checking the full set equality.
|
||||||
|
const bSet = new Set(aSet);
|
||||||
|
for (const b of aList) bSet.add(b);
|
||||||
|
// Symmetry means a's set equals the set of {b : a ∈ b}.
|
||||||
|
const reverse = Object.entries(adjacency)
|
||||||
|
.filter(([, list]) => list.includes(a))
|
||||||
|
.map(([k]) => k);
|
||||||
|
expect(new Set(reverse).size, `asymmetric degree for ${a}`).toBe(aList.length);
|
||||||
|
expect(new Set(aList).size).toBe(aSet.size); // no duplicate neighbours
|
||||||
|
}
|
||||||
|
expect(checked).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spot-checks known real bordering pairs', () => {
|
||||||
|
// Brazil ↔ Peru (Acre borders Loreto) — a real, unambiguous land border.
|
||||||
|
expect(adjacency['acre-ekzw']?.includes('loreto-ipkx')).toBe(true);
|
||||||
|
expect(adjacency['loreto-ipkx']?.includes('acre-ekzw')).toBe(true);
|
||||||
|
// France ↔ Germany (Moselle borders Baden-Württemberg) — another real border.
|
||||||
|
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
|
||||||
|
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the #31 + #41 Arctic splits: New Earth isolated; Severnaya Zemlya, Pechora and Yamal adjacency", () => {
|
||||||
|
// v0.25 (#31) split RU-ARK into the mainland (arkhangel-sk-lhbk), the
|
||||||
|
// Franz Josef Land islets (new-earth-eikt) and the Severnaya Zemlya
|
||||||
|
// islets (severnaya-zemlya-awqp). New Earth sits ~100+ km from any land
|
||||||
|
// → a genuinely isolated island (empty list).
|
||||||
|
// v0.26 (#41) repartitioned the mainland: the 5 lat < 68 parts stay
|
||||||
|
// arkhangel-sk-lhbk, and the 28 lat ≥ 68 band parts became pechora-qwhs
|
||||||
|
// (the SW cell) and yamal-gosw (the NE cell). The Severnaya Zemlya
|
||||||
|
// islets' digitized rings touch the NE band part (now yamal-gosw), not
|
||||||
|
// the trimmed core, so SZ now borders Yamal (its former mainland contact
|
||||||
|
// moved to Yamal). The trimmed core keeps its real mainland neighbours
|
||||||
|
// but loses the SZ contact. Pechora (SW) borders the existing Nenets
|
||||||
|
// region to the south and Yamal to the north.
|
||||||
|
expect(adjacency['new-earth-eikt']).toEqual([]);
|
||||||
|
expect(adjacency['severnaya-zemlya-awqp']).toEqual(['yamal-gosw']);
|
||||||
|
expect(adjacency['pechora-qwhs']).toEqual(['nenets-qyga', 'yamal-gosw']);
|
||||||
|
expect(adjacency['yamal-gosw']).toEqual(['pechora-qwhs', 'severnaya-zemlya-awqp']);
|
||||||
|
// the trimmed core keeps its real neighbours (the band's SZ contact moved to Yamal)
|
||||||
|
expect(adjacency['arkhangel-sk-lhbk']).toEqual([
|
||||||
|
'karelia-ohem',
|
||||||
|
'kirov-gfvm',
|
||||||
|
'komi-eoeh',
|
||||||
|
'murmansk-etas',
|
||||||
|
'nenets-qyga',
|
||||||
|
'vologda-ynvr',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-centroids.test.ts — validation for the committed
|
||||||
|
* `public/region-centroids.json` (built by `scripts/build-region-centroids.mjs`).
|
||||||
|
*
|
||||||
|
* The manifest maps each region key to the anchor point a map marker (v0.27,
|
||||||
|
* #44) is placed at: the centroid of the region's largest-area part, plus a
|
||||||
|
* `spread` (≈ 0.1 × that part's smallest bbox dimension, in degrees) that the
|
||||||
|
* engine uses to scatter multiple markers of one region around the centroid.
|
||||||
|
* It is a load-bearing data artifact for the marker placement path — this test
|
||||||
|
* pins its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
|
||||||
|
* and computes bbox checks by iterating coordinates directly (no `@turf`/
|
||||||
|
* geojson types, which would pull `@types/geojson` into the tsc program and
|
||||||
|
* break `WorldMap.tsx` — see the v0.17 note). Mirrors the style of
|
||||||
|
* `scripts/region-adjacency.test.ts`; the determinism test re-runs the (turf
|
||||||
|
* `.mjs`) builder and compares bytes, matching that the builder is idempotent.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CentroidEntry {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
spread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All [lon, lat] coordinate pairs of a Polygon / MultiPolygon geometry. */
|
||||||
|
function allCoords(geometry: { type?: string; coordinates?: unknown }): Array<[number, number]> {
|
||||||
|
const c = geometry?.coordinates as
|
||||||
|
| Array<Array<[number, number]>>
|
||||||
|
| Array<Array<Array<[number, number]>>>
|
||||||
|
| undefined;
|
||||||
|
if (!c) return [];
|
||||||
|
if (geometry?.type === 'Polygon') {
|
||||||
|
const rings = c as Array<Array<[number, number]>>;
|
||||||
|
const out: Array<[number, number]> = [];
|
||||||
|
for (const ring of rings) for (const p of ring) out.push(p);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (geometry?.type === 'MultiPolygon') {
|
||||||
|
const polys = c as Array<Array<Array<[number, number]>>>;
|
||||||
|
const out: Array<[number, number]> = [];
|
||||||
|
for (const poly of polys) for (const ring of poly) for (const p of ring) out.push(p);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bounding box [minLon, minLat, maxLon, maxLat] of a geometry. */
|
||||||
|
function bboxOf(geometry: { type?: string; coordinates?: unknown }): [number, number, number, number] {
|
||||||
|
let minLon = Infinity;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
for (const [x, y] of allCoords(geometry)) {
|
||||||
|
if (x < minLon) minLon = x;
|
||||||
|
if (y < minLat) minLat = y;
|
||||||
|
if (x > maxLon) maxLon = x;
|
||||||
|
if (y > maxLat) maxLat = y;
|
||||||
|
}
|
||||||
|
return [minLon, minLat, maxLon, maxLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-centroids.json', () => {
|
||||||
|
const centroids = readJson('public/region-centroids.json') as Record<string, CentroidEntry>;
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const geo = readJson('public/regions.geo.json') as {
|
||||||
|
features?: Array<{ id?: string | number; geometry?: { type?: string; coordinates?: unknown } }>;
|
||||||
|
};
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
|
||||||
|
// featureId -> feature, then regionKey -> feature (via the key manifest).
|
||||||
|
const featureByKey = new Map<string, { geometry?: { type?: string; coordinates?: unknown } }>();
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const key = manifest[String(f.id)];
|
||||||
|
if (key) featureByKey.set(key, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||||
|
expect(new Set(Object.keys(centroids))).toEqual(new Set(keys));
|
||||||
|
expect(Object.keys(centroids)).toHaveLength(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every entry is a finite { lat, lon, spread } with a non-negative spread, in world bounds', () => {
|
||||||
|
for (const [k, v] of Object.entries(centroids)) {
|
||||||
|
expect(Number.isFinite(v.lat), `non-finite lat for ${k}`).toBe(true);
|
||||||
|
expect(Number.isFinite(v.lon), `non-finite lon for ${k}`).toBe(true);
|
||||||
|
expect(Number.isFinite(v.spread), `non-finite spread for ${k}`).toBe(true);
|
||||||
|
expect(v.spread >= 0, `negative spread for ${k}`).toBe(true);
|
||||||
|
expect(v.lat >= -90 && v.lat <= 90, `lat out of range for ${k}: ${v.lat}`).toBe(true);
|
||||||
|
expect(v.lon >= -180 && v.lon <= 180, `lon out of range for ${k}: ${v.lon}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each centroid lies inside its region feature bounding box', () => {
|
||||||
|
// The recorded point is the centroid of the LARGEST part, which is a subset
|
||||||
|
// of the whole feature, so it must fall inside the feature's overall bbox.
|
||||||
|
let checked = 0;
|
||||||
|
for (const [key, c] of Object.entries(centroids)) {
|
||||||
|
const feat = featureByKey.get(key);
|
||||||
|
if (!feat?.geometry) continue;
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = bboxOf(feat.geometry);
|
||||||
|
const eps = 1e-4; // allow for the 6-dp rounding in the builder
|
||||||
|
expect(
|
||||||
|
c.lat >= minLat - eps && c.lat <= maxLat + eps,
|
||||||
|
`lat ${c.lat} outside bbox [${minLat}, ${maxLat}] for ${key}`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
c.lon >= minLon - eps && c.lon <= maxLon + eps,
|
||||||
|
`lon ${c.lon} outside bbox [${minLon}, ${maxLon}] for ${key}`,
|
||||||
|
).toBe(true);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
expect(checked).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spot-checks a couple of well-known anchors land in the right place', () => {
|
||||||
|
// Region keys are sub-national (states/provinces), so we spot-check a few
|
||||||
|
// whose geography is unambiguous. A gross error in the centroid method
|
||||||
|
// (e.g. averaging across all parts of a large multi-part region, or a
|
||||||
|
// lon/lat swap) would land one of these far off.
|
||||||
|
const near = (k: string, lat: number, lon: number, tol: number) => {
|
||||||
|
const c = centroids[k];
|
||||||
|
expect(c, `missing centroid for ${k}`).toBeDefined();
|
||||||
|
expect(Math.abs(c!.lat - lat)).toBeLessThan(tol);
|
||||||
|
expect(Math.abs(c!.lon - lon)).toBeLessThan(tol);
|
||||||
|
};
|
||||||
|
near('ain-lqtt', 46.16, 4.8, 0.5); // FR-01 — eastern France
|
||||||
|
near('california-kikh', 36.87, -120.67, 0.7); // US-CA — central California
|
||||||
|
near('alberta-zopq', 52.09, -116.94, 1.5); // CA-AB — Alberta (large province)
|
||||||
|
near('acre-ekzw', -9.49, -71.11, 0.5); // BR-AC — western Brazil
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||||
|
const file = path.join(root, 'public', 'region-centroids.json');
|
||||||
|
const before = readFileSync(file, 'utf8');
|
||||||
|
// The builder is cwd-independent (it resolves the repo root from its own
|
||||||
|
// location) and idempotent: same input files + pure turf + fixed rounding
|
||||||
|
// + stable insertion order → identical bytes.
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-centroids.mjs')], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const after = readFileSync(file, 'utf8');
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-geo.test.ts — validation for the committed
|
||||||
|
* `public/region-geo.json` (built by `scripts/build-region-geo.mjs`).
|
||||||
|
*
|
||||||
|
* The manifest maps each region key to its { area (km²), population } — the
|
||||||
|
* weighting source for the per-region stats model (v0.28, #46): a nation's
|
||||||
|
* four 0–100 stats are derived as the area×population-weighted median of the
|
||||||
|
* stats of the regions it owns. It is a load-bearing data artifact — this
|
||||||
|
* test pins its contract.
|
||||||
|
*
|
||||||
|
* The `area` is the turf area of the region's final geometry (km², 2 dp); the
|
||||||
|
* `population` is the country's Natural-Earth admin-0 `POP_EST` (from the
|
||||||
|
* committed `public/ne-admin0-pop.json`) distributed across its sub-regions
|
||||||
|
* proportional to area via largest-remainder, so every country's sub-regions
|
||||||
|
* sum EXACTLY to the country's total (and the global total is the sum over the
|
||||||
|
* 177 repo countries). This is a dependency-free test: it reads the JSON with
|
||||||
|
* plain `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull
|
||||||
|
* `@types/geojson` into the tsc program and break `WorldMap.tsx` — see the
|
||||||
|
* v0.17 note). The determinism test re-runs the (turf `.mjs`) builder and
|
||||||
|
* compares bytes, matching that the builder is idempotent.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeoEntry {
|
||||||
|
area: number;
|
||||||
|
population: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 177 repo countries' admin-0 population totals (the committed derived
|
||||||
|
// source). Only the countries actually present in the region dataset appear.
|
||||||
|
const admin0 = readJson('public/ne-admin0-pop.json') as Record<string, number>;
|
||||||
|
// feature id -> region key
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
// feature id -> { name, parent (ISO_A3) }
|
||||||
|
const regGeo = readJson('public/regions.geo.json') as {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// region key -> parent ISO_A3 (via the key manifest + the geo feature parent).
|
||||||
|
const keyToParent: Record<string, string> = {};
|
||||||
|
for (const f of regGeo.features ?? []) {
|
||||||
|
const key = manifest[String(f.id)];
|
||||||
|
const parent = f.properties?.parent;
|
||||||
|
if (key && parent) keyToParent[key] = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = readJson('public/region-geo.json') as Record<string, GeoEntry>;
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
|
||||||
|
describe('region-geo.json', () => {
|
||||||
|
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||||
|
expect(new Set(Object.keys(geo))).toEqual(new Set(keys));
|
||||||
|
expect(Object.keys(geo)).toHaveLength(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is keyed in sorted (deterministic) order', () => {
|
||||||
|
const ks = Object.keys(geo);
|
||||||
|
expect(ks).toEqual([...ks].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every entry is a finite { area ≥ 0 with ≤ 2 dp, integer population ≥ 0 }', () => {
|
||||||
|
for (const [k, v] of Object.entries(geo)) {
|
||||||
|
expect(Number.isFinite(v.area), `non-finite area for ${k}`).toBe(true);
|
||||||
|
expect(Number.isFinite(v.population), `non-finite population for ${k}`).toBe(true);
|
||||||
|
expect(v.area >= 0, `negative area for ${k}: ${v.area}`).toBe(true);
|
||||||
|
// area is recorded to at most 2 decimal places (km²)
|
||||||
|
expect(Math.round(v.area * 100) / 100 === v.area, `area over 2dp for ${k}: ${v.area}`).toBe(true);
|
||||||
|
expect(Number.isInteger(v.population), `non-integer population for ${k}: ${v.population}`).toBe(true);
|
||||||
|
expect(v.population >= 0, `negative population for ${k}: ${v.population}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the global population total equals the sum of the 177 repo countries\' admin-0 totals', () => {
|
||||||
|
// The distribution preserved each country's total exactly, so the grand
|
||||||
|
// total over all sub-regions is the sum over the repo countries only.
|
||||||
|
const total = Object.values(geo).reduce((s, v) => s + v.population, 0);
|
||||||
|
expect(total).toBe(7654092021);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves each country\'s population total across its sub-regions (largest-remainder)', () => {
|
||||||
|
// For every parent country present in the region dataset, the sum of its
|
||||||
|
// sub-regions' populations must equal that country's admin-0 POP_EST
|
||||||
|
// EXACTLY — this is the invariant the largest-remainder distribution
|
||||||
|
// guarantees (no population is created or lost by the split).
|
||||||
|
const byParent: Record<string, number> = {};
|
||||||
|
for (const [k, v] of Object.entries(geo)) {
|
||||||
|
const p = keyToParent[k];
|
||||||
|
if (!p) throw new Error(`no parent for region key ${k}`);
|
||||||
|
byParent[p] = (byParent[p] ?? 0) + v.population;
|
||||||
|
}
|
||||||
|
expect(Object.keys(byParent).length).toBe(177); // the 177 repo countries
|
||||||
|
for (const [p, sum] of Object.entries(byParent)) {
|
||||||
|
expect(admin0[p], `missing admin-0 population for ${p}`).toBeDefined();
|
||||||
|
expect(sum, `population drift for ${p}: sub-regions ${sum} vs admin-0 ${admin0[p]}`).toBe(admin0[p]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spot-checks well-known regions for exact area + population', () => {
|
||||||
|
const is = (k: string, area: number, population: number) => {
|
||||||
|
const v = geo[k];
|
||||||
|
expect(v, `missing geo for ${k}`).toBeDefined();
|
||||||
|
expect(v!.area, `area for ${k}`).toBe(area);
|
||||||
|
expect(v!.population, `population for ${k}`).toBe(population);
|
||||||
|
};
|
||||||
|
is('california-kikh', 413329.21, 14184195); // US-CA
|
||||||
|
is('arkhangel-sk-lhbk', 305257.7, 2579108); // RU-ARK mainland core
|
||||||
|
is('pechora-qwhs', 37399.76, 315989); // RU-PEO (Arctic-coast band, #41)
|
||||||
|
is('yamal-gosw', 50598.25, 427502); // RU-YAM (Arctic-coast band, #41)
|
||||||
|
is('new-earth-eikt', 15721.27, 132828); // RU-ARK-NE (island group, #31)
|
||||||
|
is('severnaya-zemlya-awqp', 1209.93, 10223); // RU-ARK-SZ (island group, #31)
|
||||||
|
is('ain-lqtt', 22557.77, 2304126); // FR-01 (Ain)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||||
|
const file = path.join(root, 'public', 'region-geo.json');
|
||||||
|
const before = readFileSync(file, 'utf8');
|
||||||
|
// The builder is cwd-independent (it resolves the repo root from its own
|
||||||
|
// location) and idempotent: same input files (regions.geo.json +
|
||||||
|
// region-keys.json + ne-admin0-pop.json) + pure turf + fixed 2-dp rounding
|
||||||
|
// + stable (sorted) key order → identical bytes.
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-geo.mjs')], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const after = readFileSync(file, 'utf8');
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-keys.test.ts — validation for the committed `public/region-keys.json`.
|
||||||
|
*
|
||||||
|
* The manifest maps each sub-region's **stable feature id** (from
|
||||||
|
* `public/regions.geo.json`) to a deterministic, human-readable, globally
|
||||||
|
* unique region key of the form `<name-slug>-<4char>` (built by
|
||||||
|
* `scripts/build-region-keys.mjs`). Region-level ownership (v0.19) keys both
|
||||||
|
* `Nation.territory` and the `RegionMap` on these keys, so the manifest is a
|
||||||
|
* load-bearing data artifact — this test pins its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads both JSON files with plain
|
||||||
|
* `fs`/`JSON.parse` and never imports `@turf`/geojson types (importing one
|
||||||
|
* pulls `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see
|
||||||
|
* the v0.17 changelog note). Mirrors the style of `scripts/regions.test.ts`.
|
||||||
|
*/
|
||||||
|
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, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-keys.json', () => {
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const geo = readJson('public/regions.geo.json') as {
|
||||||
|
features?: Array<{ id?: string | number }>;
|
||||||
|
};
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
||||||
|
|
||||||
|
it('has exactly 1,800 entries (one per region)', () => {
|
||||||
|
expect(Object.keys(manifest)).toHaveLength(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 1,800 unique keys (no collisions)', () => {
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
expect(new Set(keys).size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
|
||||||
|
const ending = /-[a-z]{4}$/;
|
||||||
|
for (const k of keys) {
|
||||||
|
expect(k, `key does not end in -[a-z]{4}: ${k}`).toMatch(ending);
|
||||||
|
expect(k, `key has no hyphen: ${k}`).toContain('-');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps RU-ARK's key stable across the #31 + #41 splits and mints the island and band keys", () => {
|
||||||
|
// v0.25 (#31) split RU-ARK into main + RU-ARK-NE + RU-ARK-SZ, and v0.26
|
||||||
|
// (#41) repartitioned the mainland into the core (RU-ARK) + RU-PEO +
|
||||||
|
// RU-YAM. The MAIN feature keeps its id (and therefore its key — FNV-1a
|
||||||
|
// of the id), which is what keeps existing Nation.territory / SQLite
|
||||||
|
// ownership valid; the new features get fresh keys derived the same way
|
||||||
|
// from their ids (no within-country name-collision salt needed — the
|
||||||
|
// slugs are unique).
|
||||||
|
expect(manifest['RU-ARK']).toBe('arkhangel-sk-lhbk'); // unchanged since v0.19
|
||||||
|
expect(manifest['RU-ARK-NE']).toBe('new-earth-eikt');
|
||||||
|
expect(manifest['RU-ARK-SZ']).toBe('severnaya-zemlya-awqp');
|
||||||
|
expect(manifest['RU-PEO']).toBe('pechora-qwhs');
|
||||||
|
expect(manifest['RU-YAM']).toBe('yamal-gosw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
|
||||||
|
// The manifest is looked up by feature id, so its key set must line up
|
||||||
|
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
||||||
|
// rebuilding the keys).
|
||||||
|
expect(featureIds).toHaveLength(1800);
|
||||||
|
const manifestIds = Object.keys(manifest);
|
||||||
|
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
/**
|
||||||
|
* 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.26 fixed point: 1,800 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 v0.25 Arkhangel'sk split (#31,
|
||||||
|
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
|
||||||
|
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ), and the v0.26
|
||||||
|
// band split (#41, scripts/split-arkhangel-band.mjs) repartitioned the
|
||||||
|
// 33-part mainland again into RU-ARK + RU-PEO + RU-YAM. Both are pure
|
||||||
|
// re-groupings: no part/ring/vertex moves, so only the region count
|
||||||
|
// changed (1,796 → 1,798 → 1,800); the part/ring/vertex totals, the
|
||||||
|
// per-feature ids/names/parents, and the feature order (the new features
|
||||||
|
// sit immediately after RU-ARK-SZ) are as pinned below. Pinning the
|
||||||
|
// signature guards against a non-idempotent or destructive re-run; both
|
||||||
|
// stages are byte-deterministic per input and idempotent (a second run
|
||||||
|
// 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(1800); // 1,796 + the #31 islands (+2) + the #41 band regions (+2)
|
||||||
|
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the #31/#41 re-partitions
|
||||||
|
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; unchanged by the #31/#41 re-partitions
|
||||||
|
console.log(
|
||||||
|
`\n v0.26 fixed point: ${regions.features.length} regions (1,796 + the #31 islands + the #41 band), ` +
|
||||||
|
`${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the five Arkhangel\'sk-family features: #31 islands + #41 band (RU-ARK, New Earth, Severnaya Zemlya, Pechora, Yamal)', () => {
|
||||||
|
// v0.25 (#31): the ~411,500 km² RU-ARK mega-region (a 78-part MultiPolygon
|
||||||
|
// mixing mainland and far-north islands) was repartitioned by part
|
||||||
|
// centroid into the mainland (RU-ARK, keeps its id/name/key), the Franz
|
||||||
|
// Josef Land islets (RU-ARK-NE "New Earth", centroid lat ≥ 79.5) and the
|
||||||
|
// Severnaya Zemlya islets (RU-ARK-SZ, centroid 76–79.5° N, lon ≥ 55°E).
|
||||||
|
// v0.26 (#41): the 33-part mainland (post-#31) was repartitioned again —
|
||||||
|
// the 5 lat < 68 parts stay RU-ARK (id/name/key unchanged), and the 28
|
||||||
|
// lat ≥ 68 "Arctic-coast band" parts split into RU-PEO "Pechora" (the SW
|
||||||
|
// Voronoi cell, 13 parts) and RU-YAM "Yamal" (the NE Voronoi cell, 15
|
||||||
|
// parts). Pure re-grouping: all five keep parent RUS and all 78 original
|
||||||
|
// parts of the pre-#31 feature are accounted for, verbatim.
|
||||||
|
const byId = new Map(regions.features.map((f) => [f.id, f]));
|
||||||
|
const ark = byId.get('RU-ARK');
|
||||||
|
const ne = byId.get('RU-ARK-NE');
|
||||||
|
const sz = byId.get('RU-ARK-SZ');
|
||||||
|
const peo = byId.get('RU-PEO');
|
||||||
|
const yam = byId.get('RU-YAM');
|
||||||
|
for (const f of [ark, ne, sz, peo, yam]) {
|
||||||
|
expect(f, `${f?.id ?? '?'} exists`).toBeDefined();
|
||||||
|
expect(f!.properties.parent, `${f!.id} parent`).toBe('RUS');
|
||||||
|
expect(f!.geometry.type, `${f!.id} geometry`).toBe('MultiPolygon');
|
||||||
|
}
|
||||||
|
expect(ark!.properties.name, 'RU-ARK keeps its name').toBe("Arkhangel'sk");
|
||||||
|
expect(ne!.properties.name, 'RU-ARK-NE name').toBe('New Earth');
|
||||||
|
expect(sz!.properties.name, 'RU-ARK-SZ name').toBe('Severnaya Zemlya');
|
||||||
|
expect(peo!.properties.name, 'RU-PEO name').toBe('Pechora');
|
||||||
|
expect(yam!.properties.name, 'RU-YAM name').toBe('Yamal');
|
||||||
|
// the 78 parts of the pre-split feature are all accounted for, verbatim
|
||||||
|
const countParts = (f: (typeof regions.features)[number]) =>
|
||||||
|
f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.length : 1;
|
||||||
|
expect(
|
||||||
|
countParts(ark!) + countParts(ne!) + countParts(sz!) + countParts(peo!) + countParts(yam!),
|
||||||
|
'total parts across the five features',
|
||||||
|
).toBe(78);
|
||||||
|
// the #41 split: the mainland core keeps its 5 lat < 68 parts; the band's
|
||||||
|
// 28 parts split 13 (Pechora) / 15 (Yamal)
|
||||||
|
expect(countParts(ark!), 'RU-ARK core part count').toBe(5);
|
||||||
|
expect(countParts(peo!), 'RU-PEO part count').toBe(13);
|
||||||
|
expect(countParts(yam!), 'RU-YAM part count').toBe(15);
|
||||||
|
// stable file order: RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM
|
||||||
|
const idx = regions.features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
expect(regions.features[idx + 1]?.id).toBe('RU-ARK-NE');
|
||||||
|
expect(regions.features[idx + 2]?.id).toBe('RU-ARK-SZ');
|
||||||
|
expect(regions.features[idx + 3]?.id).toBe('RU-PEO');
|
||||||
|
expect(regions.features[idx + 4]?.id).toBe('RU-YAM');
|
||||||
|
console.log(
|
||||||
|
`\n #31 + #41: RU-ARK ${countParts(ark!)} parts (core), RU-ARK-NE ${countParts(ne!)} (New Earth), ` +
|
||||||
|
`RU-ARK-SZ ${countParts(sz!)} (Severnaya Zemlya), RU-PEO ${countParts(peo!)} (Pechora), RU-YAM ${countParts(yam!)} (Yamal)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/split-arkhangel-band.mjs — split the RU-ARK Arctic-coast band into
|
||||||
|
* two new regions, RU-PEO ("Pechora", the SW group) and RU-YAM ("Yamal", the
|
||||||
|
* NE group) (issue #41, a follow-up to #31).
|
||||||
|
*
|
||||||
|
* The #31 split (`scripts/split-arkhangel.mjs`) left `RU-ARK` as a 33-part
|
||||||
|
* MultiPolygon (~393,256 km²). Measured on that feature (centroid latitude /
|
||||||
|
* planar area), the parts divide cleanly by centroid latitude:
|
||||||
|
*
|
||||||
|
* lat < 68 4 parts ~305,000 km² (Arkhangel'sk proper + 3 slivers)
|
||||||
|
* 66 ≤ lat < 68 1 part ~47 km²
|
||||||
|
* lat ≥ 68 28 parts ~88,000 km² (the "Arctic-coast band")
|
||||||
|
*
|
||||||
|
* The band is really *other* Arctic oblasts (Nenets / Pechora / Yamal / Taimyr
|
||||||
|
* side) mis-lumped into Arkhangel'sk during the v0.14 coarsening. This stage
|
||||||
|
* re-coarsens it: the 28 band parts are split into two new regions by a
|
||||||
|
* Voronoi of the two band parts with the largest planar area (the SW anchor
|
||||||
|
* vs the NE anchor), while the 5 lat < 68 parts stay in `RU-ARK`.
|
||||||
|
*
|
||||||
|
* RU-ARK (keeps its id and name) — the 5 lat < 68 parts (original order)
|
||||||
|
* → still the `arkhangel-sk-lhbk` key
|
||||||
|
* RU-PEO "Pechora" — the band's SW Voronoi cell (13 parts)
|
||||||
|
* RU-YAM "Yamal" — the band's NE Voronoi cell (15 parts)
|
||||||
|
*
|
||||||
|
* Deterministic, turf-free threshold spec (all data-derived — no magic
|
||||||
|
* coordinates; the same planar model the tests/adjacency build use):
|
||||||
|
* 1. centroid of a part = the mean of its outer-ring vertices (verified to
|
||||||
|
* give the same band assignment as turf's area-weighted centroid, 0 flips).
|
||||||
|
* 2. band = the parts whose centroid lat ≥ 68 (asserted to be exactly 28).
|
||||||
|
* 3. anchors = the 2 band parts with the largest planar area (≈ 47,223 and
|
||||||
|
* ≈ 32,907 km², indices 2 and 1 in the 33-part feature); anchorW = the
|
||||||
|
* anchor with the smaller centroid longitude.
|
||||||
|
* 4. each non-anchor band part → the nearer anchor by km-weighted centroid
|
||||||
|
* distance, `dist(a,b) = hypot(Δlon·111.32·cos(midLat), Δlat·110.574)`;
|
||||||
|
* a tie → west (anchorW). Each anchor → its own group. This yields
|
||||||
|
* PEO = {1,4,5,9,11,12,13,14,16,17,18,20,22} and
|
||||||
|
* YAM = {2,6,15,19,21,23,24,25,26,27,28,29,30,31,32} (0-based indices).
|
||||||
|
*
|
||||||
|
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
|
||||||
|
* moved verbatim (same coordinates, same ring order) from the one feature to
|
||||||
|
* the others. The union of the three output geometries is byte-identical to
|
||||||
|
* the input feature's parts, so no border moves and no new overlap is
|
||||||
|
* possible (the same guarantee as #31). `RU-ARK` keeps its stable feature id,
|
||||||
|
* so its region key (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
|
||||||
|
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
|
||||||
|
* new ids (`RU-PEO`, `RU-YAM`) get their own keys derived by the same FNV-1a
|
||||||
|
* rule in `scripts/build-region-keys.mjs` (`pechora-qwhs`, `yamal-gosw` —
|
||||||
|
* run it after this stage).
|
||||||
|
*
|
||||||
|
* The new features are inserted immediately after `RU-ARK-SZ` (stable file
|
||||||
|
* order, deterministic bytes: `RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM,
|
||||||
|
* …`). Each new feature's `properties` copies `RU-ARK`'s (only `name` and
|
||||||
|
* `parent` exist in this dataset) with `name` overridden; `parent` stays RUS.
|
||||||
|
* Each feature's geometry is a MultiPolygon of its assigned parts (in
|
||||||
|
* original order), or a single Polygon if it ended up with one part (it won't
|
||||||
|
* here, but the builder handles it).
|
||||||
|
*
|
||||||
|
* Pipeline position: AFTER `split-arkhangel` and BEFORE `build-region-keys`
|
||||||
|
* (the manifests must be rebuilt after any geo change):
|
||||||
|
*
|
||||||
|
* generate → coarsen → cleanup → smooth → fill-gaps → split-arkhangel
|
||||||
|
* → **split-arkhangel-band** → build-region-keys → build-region-adjacency
|
||||||
|
*
|
||||||
|
* Idempotent: when RU-PEO and RU-YAM already exist the file is left untouched
|
||||||
|
* (no write at all), so a full re-run of the pipeline reproduces byte-identical
|
||||||
|
* output.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/split-arkhangel-band.mjs [inFile [outFile]]
|
||||||
|
* (or `npm run split:regions:band`)
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
*
|
||||||
|
* Deliberately turf-free: no `@turf/*` import (turf is only ever imported by
|
||||||
|
* the geo-generator `.mjs` stages, never by this re-partition or by any `.ts`).
|
||||||
|
*/
|
||||||
|
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, '..');
|
||||||
|
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
// A part whose centroid lat is ≥ this is part of the Arctic-coast band; the
|
||||||
|
// lat < 68 core stays in RU-ARK (issue #41).
|
||||||
|
const BAND_MIN_LAT = 68;
|
||||||
|
|
||||||
|
// The post-#31 mainland feature is a 33-part MultiPolygon; any other shape
|
||||||
|
// means the file is not the state this stage expects → fail loud.
|
||||||
|
const EXPECTED_PARTS = 33;
|
||||||
|
// The Arctic-coast band is exactly 28 of those parts (lat ≥ 68).
|
||||||
|
const EXPECTED_BAND = 28;
|
||||||
|
|
||||||
|
// Planar km model (matches the test's area model and the adjacency build):
|
||||||
|
// x = lon · 111.32 · cos(midLat), y = lat · 110.574.
|
||||||
|
const KM_LON = 111.32;
|
||||||
|
const KM_LAT = 110.574;
|
||||||
|
const rad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean of a part's outer-ring vertices — a turf-free centroid. Verified to give
|
||||||
|
* the same band assignment as turf's area-weighted centroid (0 flips).
|
||||||
|
*/
|
||||||
|
function centroidOf(part) {
|
||||||
|
const ring = part[0];
|
||||||
|
let lon = 0;
|
||||||
|
let lat = 0;
|
||||||
|
for (const [lo, la] of ring) {
|
||||||
|
lon += lo;
|
||||||
|
lat += la;
|
||||||
|
}
|
||||||
|
return [lon / ring.length, lat / ring.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planar area of a part (km²), shoelace over its outer ring at its mean latitude. */
|
||||||
|
function areaKm2(part) {
|
||||||
|
const ring = part[0];
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of ring) lat += la;
|
||||||
|
const midLat = lat / ring.length;
|
||||||
|
const kx = KM_LON * Math.cos(rad(midLat));
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [lon1, lat1] = ring[i];
|
||||||
|
const [lon2, lat2] = ring[(i + 1) % ring.length];
|
||||||
|
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||||
|
}
|
||||||
|
return Math.abs(a / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planar km distance between two centroids (midpoint-latitude weighted). */
|
||||||
|
function kmDist(a, b) {
|
||||||
|
const dLon = b[0] - a[0];
|
||||||
|
const dLat = b[1] - a[1];
|
||||||
|
const midLat = (a[1] + b[1]) / 2;
|
||||||
|
return Math.hypot(dLon * KM_LON * Math.cos(rad(midLat)), dLat * KM_LAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load --------------------------------------------------------------------
|
||||||
|
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
|
||||||
|
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
|
||||||
|
throw new Error(`${inFile} is not a FeatureCollection`);
|
||||||
|
}
|
||||||
|
const features = geo.features;
|
||||||
|
|
||||||
|
// --- idempotency: already split? ----------------------------------------------
|
||||||
|
const ids = new Set(features.map((f) => String(f.id ?? '')));
|
||||||
|
if (ids.has('RU-PEO') && ids.has('RU-YAM')) {
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel-band] RU-PEO and RU-YAM already present — no-op (idempotent), ${inFile} left untouched`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load + validate RU-ARK ---------------------------------------------------
|
||||||
|
const idx = features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
if (idx < 0) throw new Error('RU-ARK not found in ' + inFile);
|
||||||
|
const ark = features[idx];
|
||||||
|
if (ark.geometry?.type !== 'MultiPolygon') {
|
||||||
|
throw new Error(
|
||||||
|
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the 33-part post-#31 mainland)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = ark.geometry.coordinates;
|
||||||
|
if (parts.length !== EXPECTED_PARTS) {
|
||||||
|
throw new Error(`RU-ARK has ${parts.length} parts, expected ${EXPECTED_PARTS} (the post-#31 mainland)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- band selection (centroid lat ≥ 68) and the lat < 68 core -----------------
|
||||||
|
const partsByIndex = parts.map((p, i) => ({ i, c: centroidOf(p) }));
|
||||||
|
const band = partsByIndex.filter((o) => o.c[1] >= BAND_MIN_LAT);
|
||||||
|
if (band.length !== EXPECTED_BAND) {
|
||||||
|
throw new Error(`expected exactly ${EXPECTED_BAND} Arctic-coast band parts (lat ≥ ${BAND_MIN_LAT}), found ${band.length}`);
|
||||||
|
}
|
||||||
|
const core = partsByIndex.filter((o) => o.c[1] < BAND_MIN_LAT);
|
||||||
|
|
||||||
|
// --- anchors: the two band parts with the largest planar area -----------------
|
||||||
|
// (≈ 47,223 and ≈ 32,907 km², indices 2 and 1 in the 33-part feature).
|
||||||
|
const byArea = [...band].sort((a, b) => areaKm2(parts[b.i]) - areaKm2(parts[a.i]));
|
||||||
|
const big = byArea[0];
|
||||||
|
const small = byArea[1];
|
||||||
|
const anchorW = big.c[0] <= small.c[0] ? big : small; // the west (SW) anchor
|
||||||
|
const anchorE = big.c[0] <= small.c[0] ? small : big; // the east (NE) anchor
|
||||||
|
|
||||||
|
// --- assignment (Voronoi by km-weighted centroid distance; tie → west) --------
|
||||||
|
const peo = new Set([anchorW.i]);
|
||||||
|
const yam = new Set([anchorE.i]);
|
||||||
|
for (const o of band) {
|
||||||
|
if (o.i === anchorW.i || o.i === anchorE.i) continue;
|
||||||
|
const dW = kmDist(o.c, anchorW.c);
|
||||||
|
const dE = kmDist(o.c, anchorE.c);
|
||||||
|
(dW <= dE ? peo : yam).add(o.i); // a tie goes to the west (anchorW) group
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild geometries (parts verbatim, original order; MultiPolygon/Polygon) --
|
||||||
|
const geom = (ps) =>
|
||||||
|
ps.length > 1 ? { type: 'MultiPolygon', coordinates: ps } : { type: 'Polygon', coordinates: ps[0] };
|
||||||
|
|
||||||
|
const coreParts = [...core].sort((a, b) => a.i - b.i).map((o) => parts[o.i]);
|
||||||
|
const peoParts = [...peo].sort((a, b) => a - b).map((i) => parts[i]);
|
||||||
|
const yamParts = [...yam].sort((a, b) => a - b).map((i) => parts[i]);
|
||||||
|
|
||||||
|
ark.geometry = geom(coreParts);
|
||||||
|
|
||||||
|
const mkFeature = (id, name, ps) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
// copy every property RU-ARK has (currently just { name, parent }), overriding name
|
||||||
|
properties: { ...ark.properties, name },
|
||||||
|
geometry: geom(ps),
|
||||||
|
});
|
||||||
|
const peoF = mkFeature('RU-PEO', 'Pechora', peoParts);
|
||||||
|
const yamF = mkFeature('RU-YAM', 'Yamal', yamParts);
|
||||||
|
|
||||||
|
// --- report -------------------------------------------------------------------
|
||||||
|
const km2 = (ps) => ps.reduce((s, p) => s + areaKm2(p), 0);
|
||||||
|
console.log(`[split-arkhangel-band] input: ${inFile}`);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK (core, lat < 68) ${coreParts.length} parts (of ${parts.length}), ≈ ${Math.round(km2(coreParts)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-PEO (Pechora, SW) ${peoParts.length} parts, ≈ ${Math.round(km2(peoParts)).toLocaleString('en-US')} km² [${[...peo].sort((a, b) => a - b).join(',')}]`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-YAM (Yamal, NE) ${yamParts.length} parts, ≈ ${Math.round(km2(yamParts)).toLocaleString('en-US')} km² [${[...yam].sort((a, b) => a - b).join(',')}]`,
|
||||||
|
);
|
||||||
|
if (peoParts.length === 0 || yamParts.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`threshold mis-split: Pechora=${peoParts.length} parts, Yamal=${yamParts.length} parts — check the band before shipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- insert at a stable position (immediately after RU-ARK-SZ) and write -------
|
||||||
|
// RU-ARK-SZ must exist (the #31 split ran first); if it doesn't, this stage is
|
||||||
|
// being run out of order → fail loud.
|
||||||
|
const szIdx = features.findIndex((f) => f.id === 'RU-ARK-SZ');
|
||||||
|
if (szIdx < 0) {
|
||||||
|
throw new Error('RU-ARK-SZ not found — the #31 split (scripts/split-arkhangel.mjs) must run before this stage');
|
||||||
|
}
|
||||||
|
features.splice(szIdx + 1, 0, peoF, yamF);
|
||||||
|
writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel-band] wrote ${outFile} — features ${features.length - 2} → ${features.length}` +
|
||||||
|
` (order …, RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM, …)`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/split-arkhangel.mjs — split the RU-ARK ("Arkhangel'sk") mega-region
|
||||||
|
* into main + two detached island archipelagos (issue #31).
|
||||||
|
*
|
||||||
|
* `RU-ARK` is a 78-part MultiPolygon of ~411,500 km²: the Arkhangel'sk
|
||||||
|
* mainland plus Arctic-coast mainland parts, tiny islets, AND two far-north
|
||||||
|
* island groups that belong nowhere near the mainland — the Franz Josef Land
|
||||||
|
* islets (New Earth, centroid lat ≥ 79.5) and the Severnaya Zemlya islets
|
||||||
|
* (centroid lat 76–79.5, lon ≥ 55). This stage repartitions the existing
|
||||||
|
* polygon parts between three features so each island group is its own
|
||||||
|
* addressable sub-region:
|
||||||
|
*
|
||||||
|
* RU-ARK (keeps its id and name) — every part whose centroid has
|
||||||
|
* lat < 76 (the mainland, Arctic-coast
|
||||||
|
* mainland parts, islets, one south dot)
|
||||||
|
* RU-ARK-NE "New Earth" — every part whose centroid has
|
||||||
|
* lat ≥ 79.5 (the 42 Franz Josef Land
|
||||||
|
* islets, ~15,700 km²)
|
||||||
|
* RU-ARK-SZ "Severnaya Zemlya" — every part whose centroid has
|
||||||
|
* 76 ≤ lat < 79.5 AND lon ≥ 55 (the 3
|
||||||
|
* Severnaya Zemlya islets, ~1,200 km²)
|
||||||
|
*
|
||||||
|
* (Parts in the 76–79.5° band with lon < 55 would stay in RU-ARK; none exist
|
||||||
|
* in the current dataset — the split is verified clean before it is written.)
|
||||||
|
*
|
||||||
|
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
|
||||||
|
* moved verbatim (same coordinates, same ring order) from the one feature to
|
||||||
|
* the other. The union of the three output geometries is byte-identical to
|
||||||
|
* the input feature's parts, so no border moves and no new overlap is
|
||||||
|
* possible. RU-ARK keeps its stable feature id, so its region key
|
||||||
|
* (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
|
||||||
|
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
|
||||||
|
* new ids (`RU-ARK-NE`, `RU-ARK-SZ`) get their own keys derived by the same
|
||||||
|
* FNV-1a rule in `scripts/build-region-keys.mjs` (run it after this stage).
|
||||||
|
*
|
||||||
|
* The new features are inserted immediately after RU-ARK (stable file order,
|
||||||
|
* deterministic bytes). Each new feature's `properties` copies RU-ARK's
|
||||||
|
* (only `name` and `parent` exist in this dataset) with `name` overridden;
|
||||||
|
* `parent` stays RUS. Each feature's geometry is a MultiPolygon of its
|
||||||
|
* assigned parts, or a single Polygon if it ends up with one part.
|
||||||
|
*
|
||||||
|
* Pipeline position: between `fill-gaps` and `build-region-keys` (the
|
||||||
|
* manifests must be rebuilt after any geo change):
|
||||||
|
*
|
||||||
|
* generate → coarsen → cleanup → smooth → fill-gaps → **split-arkhangel**
|
||||||
|
* → build-region-keys → build-region-adjacency
|
||||||
|
*
|
||||||
|
* Idempotent: when RU-ARK-NE and RU-ARK-SZ already exist the file is left
|
||||||
|
* untouched (no write at all), so a full re-run of the pipeline reproduces
|
||||||
|
* byte-identical output.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/split-arkhangel.mjs [inFile [outFile]]
|
||||||
|
* (or `npm run split:regions`)
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
*
|
||||||
|
* `@turf/centroid` + `@turf/helpers` are used only to classify each part by
|
||||||
|
* its centroid — the same measurement the split thresholds are defined in.
|
||||||
|
* (Turf is allowed in .mjs scripts only; never imported by any .ts file.)
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import centroid from '@turf/centroid';
|
||||||
|
import area from '@turf/area';
|
||||||
|
import { polygon } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
// The split thresholds (degrees), per issue #31.
|
||||||
|
const NEW_EARTH_MIN_LAT = 79.5; // centroid lat ≥ this → New Earth (Franz Josef Land)
|
||||||
|
const SZ_BAND_MIN_LAT = 76; // centroid lat ≥ this (and < NEW_EARTH_MIN_LAT)…
|
||||||
|
const SZ_MIN_LON = 55; // …and centroid lon ≥ this → Severnaya Zemlya
|
||||||
|
// (anything else — including the 76–79.5° band west of 55°E, of which the
|
||||||
|
// current dataset has none — stays in the main Arkhangel'sk feature)
|
||||||
|
|
||||||
|
/** A GeoJSON polygon part as a turf Feature (for centroid/area only). */
|
||||||
|
const partFeature = (part) => polygon(part);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A part's centroid [lon, lat]. Strict by design: a centroid that cannot be
|
||||||
|
* computed is a data error, not something to silently route to the mainland.
|
||||||
|
*/
|
||||||
|
function centroidOf(part) {
|
||||||
|
try {
|
||||||
|
return centroid(partFeature(part)).geometry.coordinates;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`cannot compute centroid for a RU-ARK part: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a centroid [lon, lat] into one of the three output buckets. */
|
||||||
|
function bucketOf([lon, lat]) {
|
||||||
|
if (lat >= NEW_EARTH_MIN_LAT) return 'ne';
|
||||||
|
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon >= SZ_MIN_LON) return 'sz';
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load --------------------------------------------------------------------
|
||||||
|
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
|
||||||
|
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
|
||||||
|
throw new Error(`${inFile} is not a FeatureCollection`);
|
||||||
|
}
|
||||||
|
const features = geo.features;
|
||||||
|
|
||||||
|
// --- idempotency: already split? ----------------------------------------------
|
||||||
|
const ids = new Set(features.map((f) => String(f.id ?? '')));
|
||||||
|
if (ids.has('RU-ARK-NE') && ids.has('RU-ARK-SZ')) {
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel] RU-ARK-NE and RU-ARK-SZ already present — no-op (idempotent), ${inFile} left untouched`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new Error('RU-ARK not found in ' + inFile);
|
||||||
|
}
|
||||||
|
const ark = features[idx];
|
||||||
|
if (ark.geometry?.type !== 'MultiPolygon') {
|
||||||
|
throw new Error(
|
||||||
|
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the split expects the pre-#31 mega-region)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = ark.geometry.coordinates;
|
||||||
|
|
||||||
|
// --- partition (stable: original part order is kept inside each bucket) -------
|
||||||
|
const buckets = { main: [], ne: [], sz: [] };
|
||||||
|
let bandWest = 0; // 76–79.5°-band parts west of 55°E (stay in main; 0 in the current data)
|
||||||
|
for (const part of parts) {
|
||||||
|
const [lon, lat] = centroidOf(part);
|
||||||
|
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon < SZ_MIN_LON) bandWest += 1;
|
||||||
|
buckets[bucketOf([lon, lat])].push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild geometries (parts verbatim; MultiPolygon, or Polygon if 1 part) --
|
||||||
|
const geom = (ps) =>
|
||||||
|
ps.length > 1
|
||||||
|
? { type: 'MultiPolygon', coordinates: ps }
|
||||||
|
: { type: 'Polygon', coordinates: ps[0] };
|
||||||
|
|
||||||
|
ark.geometry = geom(buckets.main);
|
||||||
|
|
||||||
|
const mkFeature = (id, name, ps) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
// copy every property RU-ARK has (currently just { name, parent }), overriding name
|
||||||
|
properties: { ...ark.properties, name },
|
||||||
|
geometry: geom(ps),
|
||||||
|
});
|
||||||
|
const ne = mkFeature('RU-ARK-NE', 'New Earth', buckets.ne);
|
||||||
|
const sz = mkFeature('RU-ARK-SZ', 'Severnaya Zemlya', buckets.sz);
|
||||||
|
|
||||||
|
// --- report -------------------------------------------------------------------
|
||||||
|
const km2 = (ps) => ps.reduce((s, p) => s + area(partFeature(p)) * 1e-6, 0);
|
||||||
|
console.log(`[split-arkhangel] input: ${inFile}`);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK (main) ${buckets.main.length} parts (of ${parts.length}), ≈ ${Math.round(km2(buckets.main)).toLocaleString('en-US')} km²` +
|
||||||
|
(bandWest ? ` — incl. ${bandWest} part(s) in the 76–79.5° band west of 55°E (kept in main)` : ''),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK-NE (New Earth) ${buckets.ne.length} parts, ≈ ${Math.round(km2(buckets.ne)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK-SZ (Severnaya Zemlya) ${buckets.sz.length} parts, ≈ ${Math.round(km2(buckets.sz)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
if (buckets.ne.length === 0 || buckets.sz.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`threshold mis-split: New Earth=${buckets.ne.length} parts, Severnaya Zemlya=${buckets.sz.length} parts — check the centroids before shipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- insert at a stable position (immediately after RU-ARK) and write ---------
|
||||||
|
features.splice(idx + 1, 0, ne, sz);
|
||||||
|
writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(`[split-arkhangel] wrote ${outFile} — features ${features.length - 2} → ${features.length}`);
|
||||||
@@ -23,6 +23,21 @@ function makeState(id: string): GameState {
|
|||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
// (v0.28, #46) a v0.28+ save carries per-region stats; the fake 'GBR'
|
||||||
|
// region here holds a copy of the uk's (single-region) stats.
|
||||||
|
regionStats: {
|
||||||
|
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
|
},
|
||||||
|
// (v0.33.0, #56) no wars.
|
||||||
|
wars: [],
|
||||||
|
// (v0.35.0, #58) no active trades or embargoes.
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) no industry projects.
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +87,153 @@ describe('openDb', () => {
|
|||||||
expect(db.load('missing')).toBeNull();
|
expect(db.load('missing')).toBeNull();
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('(v0.22 #16 P3) a legacy row saved before the unowned field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { unowned?: string[] }).unowned; // a pre-v0.22 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.unowned).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.23 #16 P4a) a legacy row saved before the pendingNationOps field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { pendingNationOps?: unknown[] }).pendingNationOps; // a pre-v0.23 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.pendingNationOps).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) a legacy row saved before the markers field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { markers?: unknown[] }).markers; // a pre-v0.27 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.markers).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.28 #46) a legacy row saved before the regionStats field existed loads with it seeded per region and stats recomputed', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { regionStats?: Record<string, unknown> }).regionStats; // a pre-v0.28 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
// each owned region is seeded with a copy of its owner's stats…
|
||||||
|
expect(loaded!.regionStats).toEqual({
|
||||||
|
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
|
});
|
||||||
|
// …and a single-region nation's derived stats equal the seeded ones
|
||||||
|
// (the weighted median of identical values is that value).
|
||||||
|
expect(loaded!.nations.uk.stats).toEqual({ military: 80, economy: 70, stability: 60, population: 50 });
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.28 #46) a v0.28+ row (regionStats present) loads UNCHANGED — no recompute on load', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const s = makeState('current');
|
||||||
|
// Diverge one region's stats from the nation level to prove load does not
|
||||||
|
// recompute (a v0.28+ save keeps its stored derived stats exactly).
|
||||||
|
s.regionStats = { GBR: { military: 99, economy: 70, stability: 60, population: 50 } };
|
||||||
|
s.nations = { uk: { ...s.nations.uk, stats: { military: 42, economy: 70, stability: 60, population: 50 } } };
|
||||||
|
db.save(s);
|
||||||
|
const loaded = db.load('current');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.regionStats).toEqual({ GBR: { military: 99, economy: 70, stability: 60, population: 50 } });
|
||||||
|
// the nation's stats are NOT recomputed from the region on load (they stay 42)
|
||||||
|
expect(loaded!.nations.uk.stats.military).toBe(42);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.35.0 #58) a legacy row saved before the trades/embargoes fields existed loads with them defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { trades?: unknown[] }).trades; // a pre-v0.35 save (fields absent)
|
||||||
|
delete (legacy as { embargoes?: unknown[] }).embargoes;
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.trades).toEqual([]);
|
||||||
|
expect(loaded!.embargoes).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.36.0 #59) a legacy row saved before the industry field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { industry?: unknown[] }).industry; // a pre-v0.36 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.industry).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.37.0 #60) a legacy row saved before the nation nuclear flag existed loads with it absent (not capable)', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
// makeState has no nation.nuclear field — a pre-v0.37 save. The flag is
|
||||||
|
// optional and not re-stamped by load, so an absent flag stays absent.
|
||||||
|
db.save(makeState('legacy'));
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.nations.uk.nuclear).toBeUndefined();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.37.0 #60) a set nation nuclear flag round-trips through save/load', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const s = makeState('nuc');
|
||||||
|
s.nations.uk.nuclear = true; // armed (e.g. by a turn's nuclear.arm op)
|
||||||
|
db.save(s);
|
||||||
|
const loaded = db.load('nuc');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.nations.uk.nuclear).toBe(true);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.38.0 #61) a legacy row saved before the nation leader field existed loads with it absent (no leader)', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
// makeState has no nation.leader field — a pre-v0.38 save. The field is
|
||||||
|
// optional and not re-stamped by load, so an absent leader stays absent.
|
||||||
|
db.save(makeState('legacy'));
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.nations.uk.leader).toBeUndefined();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.38.0 #61) a set nation leader round-trips through save/load', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const s = makeState('ldr');
|
||||||
|
s.nations.uk.leader = { name: 'Admiral Old' };
|
||||||
|
db.save(s);
|
||||||
|
const loaded = db.load('ldr');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.nations.uk.leader).toEqual({ name: 'Admiral Old' });
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-1
@@ -2,12 +2,14 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
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 { migrateRegionStats } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
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,34 @@ 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;
|
let state = JSON.parse(row.data) as GameState;
|
||||||
|
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||||
|
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||||
|
if (state.unowned === undefined) state.unowned = [];
|
||||||
|
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
||||||
|
// has none awaiting confirm/reject.
|
||||||
|
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||||
|
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||||
|
if (state.markers === undefined) state.markers = [];
|
||||||
|
// (v0.33.0, #56) Same for named wars: a legacy game has none declared
|
||||||
|
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
|
||||||
|
// syncWarRelations, which never force-creates a War entity).
|
||||||
|
if (state.wars === undefined) state.wars = [];
|
||||||
|
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
|
||||||
|
// legacy game has no active trades or embargoes.
|
||||||
|
if (state.trades === undefined) state.trades = [];
|
||||||
|
if (state.embargoes === undefined) state.embargoes = [];
|
||||||
|
// (v0.36, #59) Same for the industry layer: a legacy game has no
|
||||||
|
// active industry projects.
|
||||||
|
if (state.industry === undefined) state.industry = [];
|
||||||
|
// (v0.28, #46) Rows saved before per-region stats existed are migrated
|
||||||
|
// ONCE at load time (and never re-migrated afterwards — the field is
|
||||||
|
// then present): each owned region is seeded with a copy of its owner's
|
||||||
|
// nation-level stats and the nations' stats are recomputed as the
|
||||||
|
// (uniform-weight) area×population-weighted median, so no data is lost
|
||||||
|
// and a single-region nation keeps exactly its old stats.
|
||||||
|
state = migrateRegionStats(state);
|
||||||
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -45,6 +75,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();
|
||||||
},
|
},
|
||||||
|
|||||||
+4405
-16
File diff suppressed because it is too large
Load Diff
+2788
-56
File diff suppressed because it is too large
Load Diff
+250
-6
@@ -8,19 +8,26 @@ import { existsSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { deepClone } from '../shared/protocol';
|
import { deepClone, seedRegionStats } from '../shared/protocol';
|
||||||
import type {
|
import type {
|
||||||
AddActionRequest,
|
AddActionRequest,
|
||||||
AddDiplomacyRequest,
|
AddDiplomacyRequest,
|
||||||
|
ConfirmNationOpRequest,
|
||||||
CreateGameRequest,
|
CreateGameRequest,
|
||||||
|
RejectNationOpRequest,
|
||||||
RemoveQueueItemRequest,
|
RemoveQueueItemRequest,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState, Preset } from '../shared/types';
|
||||||
|
import type { WorldDraft } from '../shared/scenario';
|
||||||
import { createLlmClient } from './llm';
|
import { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
import { makeScenarioStubLlmClient } from './stubScenario';
|
||||||
|
import { finalizeWorld, generateDraft } from './scenario';
|
||||||
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
|
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, hasResources, loadRegions, neighboursOf, referenceForKeys, regionName, resourceOf, validCountryCodes, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
|
import { createTemplateStore } from './templates';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||||
@@ -49,10 +56,19 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
|||||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
const presetStore = createPresetStore(presetsDir);
|
||||||
|
// (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/).
|
||||||
|
const templateStore = createTemplateStore(path.join(root, 'presets', 'templates'));
|
||||||
|
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||||
|
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||||
|
loadRegions(root);
|
||||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
const useStub = process.env.LLM_STUB === '1';
|
const useStub = process.env.LLM_STUB === '1';
|
||||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||||||
|
// (v0.31, #52) The scenario-generation LLM client: the deterministic scenario
|
||||||
|
// stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays
|
||||||
|
// untouched), and the live client otherwise.
|
||||||
|
const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -89,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void {
|
|||||||
|
|
||||||
// --- Routes ---
|
// --- Routes ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a
|
||||||
|
* dynamically generated game (`presetId: 'dynamic'` — there is no static preset
|
||||||
|
* file on disk). `runTurn` only consumes `title` / `startDate` /
|
||||||
|
* `worldBackground` / `simulationRules` (never `preset.nations`), so a generic
|
||||||
|
* alternate-history background plus the standard rules is sufficient; `nations`
|
||||||
|
* is carried from the saved world so the type is complete. `startDate` uses the
|
||||||
|
* game's current date (GameState stores no separate start date) — it only feeds
|
||||||
|
* the static "WORLD: <title> — <date>" header of the system prompt.
|
||||||
|
*/
|
||||||
|
function dynamicPresetFromGame(game: GameState): Preset {
|
||||||
|
return {
|
||||||
|
id: game.presetId,
|
||||||
|
title: game.presetTitle,
|
||||||
|
startDate: game.date,
|
||||||
|
summary: game.presetTitle,
|
||||||
|
worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`,
|
||||||
|
simulationRules: [
|
||||||
|
"Advance the shared world for ALL nations, not only the player's.",
|
||||||
|
'Keep every stat value between 0 and 100.',
|
||||||
|
'Only change borders when war or a decisive political shift justifies it.',
|
||||||
|
'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.',
|
||||||
|
'Use only nation ids and region keys that exist in the provided state.',
|
||||||
|
],
|
||||||
|
nations: Object.values(game.nations),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true, stub: useStub, port: PORT });
|
res.json({ ok: true, stub: useStub, port: PORT });
|
||||||
});
|
});
|
||||||
@@ -120,7 +164,40 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1, // the first saved revision; every later mutation bumps it
|
version: 1, // the first saved revision; every later mutation bumps it
|
||||||
|
// (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game
|
||||||
|
// starts with nothing released; this list grows as the LLM releases
|
||||||
|
// regions and shrinks as they get re-claimed.
|
||||||
|
unowned: [],
|
||||||
|
// (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the
|
||||||
|
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||||||
|
// shrinks as the player confirms or rejects them.
|
||||||
|
pendingNationOps: [],
|
||||||
|
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||||
|
markers: [],
|
||||||
|
// (v0.28, #46) Per-region stats are seeded below, once territories hold
|
||||||
|
// real region keys — each region starts as a copy of its owner's initial
|
||||||
|
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||||||
|
regionStats: {},
|
||||||
|
// (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time
|
||||||
|
// (any preset-level `war` relation stays a bare/implicit one, not an
|
||||||
|
// entity — see syncWarRelations).
|
||||||
|
wars: [],
|
||||||
|
// (v0.35.0, #58) No active trades or embargoes on a fresh game; the LLM
|
||||||
|
// declares (and lifts) them over time via TurnResult.economy.
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) No active industry projects on a fresh game; the LLM
|
||||||
|
// declares them over time via TurnResult.industry (one per region, a
|
||||||
|
// per-region SET).
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
|
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||||
|
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||||||
|
// (v0.28, #46): seed per-region stats AFTER the expansion so each owned
|
||||||
|
// region key gets its owner's initial stats (see seedRegionStats).
|
||||||
|
state.regionStats = seedRegionStats(state.nations);
|
||||||
db.save(state);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
});
|
});
|
||||||
@@ -156,6 +233,17 @@ app.get('/api/games/:id/map', (req, res) => {
|
|||||||
res.json({ regions: buildRegionMap(game) });
|
res.json({ regions: buildRegionMap(game) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on
|
||||||
|
// success broadcast an SSE `error` to any other open tab watching it so it
|
||||||
|
// returns home (see the `event: error` handling in src/lib/sse.ts).
|
||||||
|
app.delete('/api/games/:id', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const removed = db.delete(id);
|
||||||
|
if (!removed) return res.status(404).json({ error: 'game not found' });
|
||||||
|
broadcast(id, 'error', { message: 'game deleted' });
|
||||||
|
res.json({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Queue a free-text player action onto the pending round. No LLM call and no
|
// Queue a free-text player action onto the pending round. No LLM call and no
|
||||||
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
||||||
app.post('/api/games/:id/action', (req, res) => {
|
app.post('/api/games/:id/action', (req, res) => {
|
||||||
@@ -201,6 +289,38 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
|
|||||||
res.json({ game: next });
|
res.json({ game: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
|
||||||
|
// waiting in game.pendingNationOps: a rename takes effect (body.edits.name
|
||||||
|
// may override the proposed name), a split partitions the nation's territory
|
||||||
|
// into the proposed parts (body.edits.playerPart picks which part the player
|
||||||
|
// leads, default 0), and a merge folds its inputs into the survivor
|
||||||
|
// (body.edits.name may override the merged name). No LLM call, no turn
|
||||||
|
// advance — same fast save → broadcast → respond order as /queue/remove.
|
||||||
|
// The version bump lets "never go backwards" clients reconcile.
|
||||||
|
app.post('/api/games/:id/nation-op/confirm', (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 ConfirmNationOpRequest;
|
||||||
|
const next = confirmNationOp(game, body.index, body.edits);
|
||||||
|
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||||||
|
db.save(next);
|
||||||
|
broadcast(id, 'state', { state: next });
|
||||||
|
res.json({ game: next });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/games/:id/nation-op/reject', (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 RejectNationOpRequest;
|
||||||
|
const next = rejectNationOp(game, body.index);
|
||||||
|
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||||||
|
db.save(next);
|
||||||
|
broadcast(id, 'state', { state: next });
|
||||||
|
res.json({ game: next });
|
||||||
|
});
|
||||||
|
|
||||||
// Jump forward: commit the whole pending round (or an empty one) to the model
|
// Jump forward: commit the whole pending round (or an empty one) to the model
|
||||||
// in a single turn. applyTurnResult clears the pending queue on the returned
|
// in a single turn. applyTurnResult clears the pending queue on the returned
|
||||||
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||||||
@@ -208,14 +328,58 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const game = db.load(id);
|
const game = db.load(id);
|
||||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
const preset = presetStore.get(game.presetId);
|
let preset = presetStore.get(game.presetId);
|
||||||
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
if (!preset) {
|
||||||
|
// (v0.31, #52) A dynamically generated game has no static preset file:
|
||||||
|
// synthesize one from the saved world so the turn engine can run. A
|
||||||
|
// genuinely-missing static preset still 404s.
|
||||||
|
if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' });
|
||||||
|
preset = dynamicPresetFromGame(game);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { state, result } = await runTurn({
|
const { state, result } = await runTurn({
|
||||||
llm,
|
llm,
|
||||||
preset,
|
preset,
|
||||||
state: game,
|
state: game,
|
||||||
items: game.pending,
|
items: game.pending,
|
||||||
|
// (v0.22, #16 P3) the region reference table must also cover released
|
||||||
|
// (no-man's-land) keys — they belong to no nation's territory, but the
|
||||||
|
// model needs their names to re-claim them.
|
||||||
|
regionReference: referenceForKeys(
|
||||||
|
new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]),
|
||||||
|
),
|
||||||
|
validRegionKeys: validKeys(),
|
||||||
|
// v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the
|
||||||
|
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||||||
|
// tool and the player-frontier prompt field (both switch off without it).
|
||||||
|
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
|
||||||
|
// (v0.27, #44) when the centroid manifest loaded, hand the engine the
|
||||||
|
// resolver so a turn can place new map markers at their region's
|
||||||
|
// centroid (without it, marker adds are dropped; existing markers are
|
||||||
|
// still maintained).
|
||||||
|
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
||||||
|
// (v0.28, #46) when the region-geo manifest (area + population) loaded,
|
||||||
|
// hand the engine the resolver so the end-of-turn recompute weights each
|
||||||
|
// owned region by area×population (without it the aggregate degrades to
|
||||||
|
// an unweighted median — still region-consistent, just unweighted).
|
||||||
|
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
|
||||||
|
// (v0.29, #46 PR 2) region ref resolver: lets the model name a
|
||||||
|
// regionChanges target by its human-readable name (resolved
|
||||||
|
// case-insensitively against the region-key manifest) or its key;
|
||||||
|
// unknown / ambiguous refs are dropped. Always on — the resolver is
|
||||||
|
// pure (validKeys + regionName) and degrades to a key-only match when
|
||||||
|
// the manifest is absent.
|
||||||
|
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
||||||
|
// (v0.30, #50) region display name for the engine's "invasion repelled"
|
||||||
|
// event (geo properties.name, else the raw key). Always on — the
|
||||||
|
// resolver is pure and degrades to the key when the manifest is absent.
|
||||||
|
regionNameOf: (k) => regionName(k),
|
||||||
|
// (v0.36.0, #59) when the region-resources manifest (the synthesized
|
||||||
|
// per-region resource profiles) loaded, hand the engine the resolver —
|
||||||
|
// the industry boost is then scaled by each region's `industry` value
|
||||||
|
// (without it boosts use the unscaled base value) and each nation's
|
||||||
|
// aggregate resource profile is carried in the user prompt.
|
||||||
|
resourceOf: hasResources() ? (key) => resourceOf(key) : undefined,
|
||||||
});
|
});
|
||||||
db.save(state);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
@@ -245,6 +409,86 @@ app.post('/api/games/:id/advisor', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Dynamic scenario generation (v0.31, #52) ---
|
||||||
|
//
|
||||||
|
// The additive "Create scenario" path. Step 1 (POST /draft) designs a draft
|
||||||
|
// world + an advisor checkpoint list; the client shows both, the player
|
||||||
|
// approves/removes checkpoints and picks a nation; step 2 (POST /commit)
|
||||||
|
// finalizes the world (constrained to the approved checkpoints) into a saved,
|
||||||
|
// playable game. Both steps ride the scenario LLM client (the deterministic
|
||||||
|
// scenario stub under LLM_STUB=1, the live model otherwise). All existing
|
||||||
|
// routes are untouched.
|
||||||
|
|
||||||
|
app.get('/api/scenarios/templates', (_req, res) => {
|
||||||
|
res.json({ templates: templateStore.listMeta() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scenarios/draft', async (req, res) => {
|
||||||
|
const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number };
|
||||||
|
const template = templateStore.get(body.templateId ?? '');
|
||||||
|
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||||
|
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||||
|
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||||
|
const codes = validCountryCodes();
|
||||||
|
try {
|
||||||
|
const { world, checkpoints } = await generateDraft({
|
||||||
|
llm: scenarioLlm,
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
// No region data → no code filtering (the draft keeps whatever codes the
|
||||||
|
// model emitted; expansion degrades to the raw keys, like presets do).
|
||||||
|
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||||
|
});
|
||||||
|
if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' });
|
||||||
|
res.json({ world, checkpoints });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
res.status(502).json({ error: `generate failed: ${msg}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scenarios/commit', async (req, res) => {
|
||||||
|
const body = (req.body ?? {}) as {
|
||||||
|
templateId?: string;
|
||||||
|
description?: string;
|
||||||
|
year?: number;
|
||||||
|
draft?: WorldDraft;
|
||||||
|
approvedCheckpoints?: string[];
|
||||||
|
playerNationId?: string;
|
||||||
|
};
|
||||||
|
const template = templateStore.get(body.templateId ?? '');
|
||||||
|
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||||
|
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||||
|
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||||
|
const approvedCheckpoints = Array.isArray(body.approvedCheckpoints)
|
||||||
|
? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '')
|
||||||
|
: [];
|
||||||
|
const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : '';
|
||||||
|
const codes = validCountryCodes();
|
||||||
|
try {
|
||||||
|
const state = await finalizeWorld({
|
||||||
|
llm: scenarioLlm,
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
approvedCheckpoints,
|
||||||
|
draft: body.draft,
|
||||||
|
playerNationId,
|
||||||
|
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||||
|
expand: (cs) => expandCountriesToKeys(cs),
|
||||||
|
id: randomUUID(),
|
||||||
|
title: template.name,
|
||||||
|
startDate: `${year}-01-01`,
|
||||||
|
});
|
||||||
|
db.save(state);
|
||||||
|
res.json({ id: state.id });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
res.status(502).json({ error: `commit failed: ${msg}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- SSE stream ---
|
// --- SSE stream ---
|
||||||
app.get('/sse/:id', (req, res) => {
|
app.get('/sse/:id', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
@@ -282,4 +526,4 @@ app.listen(PORT, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||||
export { app, db, llm };
|
export { app, db, llm, scenarioLlm, templateStore };
|
||||||
|
|||||||
@@ -208,3 +208,182 @@ describe('createLlmClient (injectable transport)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createLlmClient.chatWithTools (function-calling loop, v0.21 #16 P2)', () => {
|
||||||
|
const tool = {
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'getNeighbours',
|
||||||
|
description: 'list bordering region keys',
|
||||||
|
parameters: { type: 'object' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolCallResponse(id: string, name: string, args: string) {
|
||||||
|
return json({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
message: {
|
||||||
|
content: null,
|
||||||
|
tool_calls: [{ id, type: 'function', function: { name, arguments: args } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentResponse(content: string) {
|
||||||
|
return json({ choices: [{ finish_reason: 'stop', message: { content } }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends tools in the first request; echoes the assistant tool_calls + tool result; the executor gets parsed args', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||||
|
return contentResponse('final answer');
|
||||||
|
};
|
||||||
|
const executorCalls: Array<{ name: string; args: Record<string, unknown> }> = [];
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
(name, args) => {
|
||||||
|
executorCalls.push({ name, args });
|
||||||
|
return JSON.stringify({ neighbours: ['b'] });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out).toBe('final answer');
|
||||||
|
expect(sent).toHaveLength(2);
|
||||||
|
// request 1: the tools array is present, history is just the initial message
|
||||||
|
const body1 = JSON.parse(sent[0]);
|
||||||
|
expect(body1.tools).toEqual([tool]);
|
||||||
|
expect(body1.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
||||||
|
// request 2: the assistant tool-call and the tool result are echoed back
|
||||||
|
const body2 = JSON.parse(sent[1]);
|
||||||
|
expect(body2.tools).toEqual([tool]);
|
||||||
|
expect(body2.messages).toEqual([
|
||||||
|
{ role: 'user', content: 'hi' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'getNeighbours', arguments: '{"regionKeys":["a"]}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: 'tool', tool_call_id: 'call_1', content: JSON.stringify({ neighbours: ['b'] }) },
|
||||||
|
]);
|
||||||
|
// the executor received the parsed (object) arguments, not the raw string
|
||||||
|
expect(executorCalls).toEqual([{ name: 'getNeighbours', args: { regionKeys: ['a'] } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a content-only response ends in one round (the executor is never called)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
return contentResponse('done');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => {
|
||||||
|
throw new Error('the executor must not be called');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out).toBe('done');
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds the tool rounds: maxRounds tool-call rounds, then one final no-tools request', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
n += 1;
|
||||||
|
if (n <= 2) return toolCallResponse(`call_${n}`, 'getNeighbours', '{}');
|
||||||
|
return contentResponse('committed');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => JSON.stringify({ neighbours: [] }),
|
||||||
|
{ maxRounds: 2 },
|
||||||
|
);
|
||||||
|
expect(out).toBe('committed');
|
||||||
|
expect(sent).toHaveLength(3); // 2 tool rounds + the forced final request
|
||||||
|
expect(JSON.parse(sent[2]).tools).toBeUndefined(); // the final request has no tools
|
||||||
|
});
|
||||||
|
|
||||||
|
it('malformed tool arguments produce an "invalid tool arguments" tool message (no throw)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
n += 1;
|
||||||
|
if (n === 1) return toolCallResponse('call_1', 'getNeighbours', '{not valid json');
|
||||||
|
return contentResponse('recovered');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => {
|
||||||
|
throw new Error('the executor must not be called on malformed args');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out).toBe('recovered');
|
||||||
|
const body2 = JSON.parse(sent[1]);
|
||||||
|
const toolMsg = body2.messages.find((m: { role?: string }) => m.role === 'tool');
|
||||||
|
expect(toolMsg).toBeDefined();
|
||||||
|
expect(toolMsg.content).toMatch(/invalid tool arguments/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a tool-call round on transient failure and still resolves the tool (500 → tool_calls → content)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
n += 1;
|
||||||
|
if (n === 1) return httpError(500);
|
||||||
|
if (n === 2) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||||
|
return contentResponse('ok');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({
|
||||||
|
transport,
|
||||||
|
apiBase: 'http://x/v1',
|
||||||
|
model: 'm',
|
||||||
|
retryBaseDelayMs: 1,
|
||||||
|
});
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => JSON.stringify({ neighbours: [] }),
|
||||||
|
);
|
||||||
|
expect(out).toBe('ok');
|
||||||
|
expect(sent).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tool-call response is never treated as a truncation failure (content null is fine)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{}');
|
||||||
|
return contentResponse('ok');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => JSON.stringify({ neighbours: [] }),
|
||||||
|
);
|
||||||
|
expect(out).toBe('ok');
|
||||||
|
expect(sent).toHaveLength(2); // no retry of the first (successful) round
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+189
-9
@@ -23,12 +23,55 @@
|
|||||||
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
|
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
|
||||||
// deterministic client error and throws immediately, with no retry. Every
|
// deterministic client error and throws immediately, with no retry. Every
|
||||||
// call is logged: one line on success, one on final failure.
|
// call is logged: one line on success, one on final failure.
|
||||||
|
//
|
||||||
|
// Function calling: `chatWithTools()` extends `chat()` with the OpenAI tool
|
||||||
|
// loop. When the model answers with `finish_reason: "tool_calls"`, the client
|
||||||
|
// runs the provided executor for every requested call, appends the assistant
|
||||||
|
// tool-call message plus one `tool` result message per call, and re-sends the
|
||||||
|
// growing history until the model produces a final `content`. The loop is
|
||||||
|
// bounded to DEFAULT_MAX_TOOL_ROUNDS (6) tool rounds; if the budget is spent
|
||||||
|
// without a final answer, one last *no-tools* request forces the model to
|
||||||
|
// commit. The same retry/backoff/reasoning-effort machinery applies per
|
||||||
|
// request, so an injectable transport only has to forward `tools` in the body
|
||||||
|
// and accept `assistant`/`tool` messages in the history. The deterministic
|
||||||
|
// stub answers plain `content` (no tool calls) and therefore bypasses the
|
||||||
|
// loop in a single round.
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One OpenAI-style function call the model requests (`finish_reason: "tool_calls"`). */
|
||||||
|
export interface ToolCallSpec {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A function the model may call (one OpenAI `tools` entry). */
|
||||||
|
export interface ToolDefinition {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full message vocabulary a request can carry: plain chat messages, plus
|
||||||
|
* assistant messages that request tool calls and the `tool` results that
|
||||||
|
* answer them (the OpenAI function-calling wire shape).
|
||||||
|
*/
|
||||||
|
export type WireMessage =
|
||||||
|
| ChatMessage
|
||||||
|
| { role: 'assistant'; content: string | null; tool_calls: ToolCallSpec[] }
|
||||||
|
| { role: 'tool'; tool_call_id: string; content: string };
|
||||||
|
|
||||||
|
/** Executes one tool call; the returned string is fed back to the model as the `tool` result. */
|
||||||
|
export type ToolExecutor = (name: string, args: Record<string, unknown>) => string;
|
||||||
|
|
||||||
/** A network call. The default implementation is native fetch. */
|
/** A network call. The default implementation is native fetch. */
|
||||||
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
@@ -39,6 +82,18 @@ export interface ChatOptions {
|
|||||||
|
|
||||||
export interface LlmClient {
|
export interface LlmClient {
|
||||||
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
||||||
|
/**
|
||||||
|
* The function-calling variant of chat() — see the file header for the loop
|
||||||
|
* semantics (bounded tool rounds, then a forced final answer). Optional on
|
||||||
|
* the interface: minimal test doubles may omit it, and callers (the
|
||||||
|
* engine's runTurn) fall back to chat() when it is absent.
|
||||||
|
*/
|
||||||
|
chatWithTools?(
|
||||||
|
messages: WireMessage[],
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
executeTool: ToolExecutor,
|
||||||
|
opts?: ChatOptions & { maxRounds?: number },
|
||||||
|
): Promise<string>;
|
||||||
listModels(): Promise<string[]>;
|
listModels(): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +118,17 @@ export const DEFAULT_MAX_TOKENS = 4000;
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_REASONING_EFFORT = 'low';
|
export const DEFAULT_REASONING_EFFORT = 'low';
|
||||||
|
|
||||||
/** Total attempts `chat()` makes before giving up (1 initial + 2 retries). */
|
/** Total attempts per request (chat / chatWithTools) before giving up (1 initial + 2 retries). */
|
||||||
export const MAX_ATTEMPTS = 3;
|
export const MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max tool-call rounds (assistant tool_calls → tool results) a chatWithTools
|
||||||
|
* loop may make before a final no-tools request forces the answer. Keeps a
|
||||||
|
* turn bounded even if the model keeps asking for data; a realistic turn uses
|
||||||
|
* 0–2 rounds.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_MAX_TOOL_ROUNDS = 6;
|
||||||
|
|
||||||
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
|
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
|
||||||
const RETRY_BASE_DELAY_MS = 2000;
|
const RETRY_BASE_DELAY_MS = 2000;
|
||||||
|
|
||||||
@@ -154,6 +217,30 @@ function isNetworkError(err: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the wire `tool_calls` array into validated ToolCallSpecs (malformed entries are dropped). */
|
||||||
|
function parseToolCalls(raw: unknown): ToolCallSpec[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: ToolCallSpec[] = [];
|
||||||
|
for (const tc of raw) {
|
||||||
|
if (!tc || typeof tc !== 'object') continue;
|
||||||
|
const t = tc as Record<string, unknown>;
|
||||||
|
const fn = t.function as Record<string, unknown> | undefined;
|
||||||
|
if (!fn || typeof fn.name !== 'string' || fn.name === '') continue;
|
||||||
|
const argumentsText =
|
||||||
|
typeof fn.arguments === 'string'
|
||||||
|
? fn.arguments
|
||||||
|
: fn.arguments != null
|
||||||
|
? JSON.stringify(fn.arguments)
|
||||||
|
: '{}';
|
||||||
|
out.push({
|
||||||
|
id: typeof t.id === 'string' && t.id !== '' ? t.id : `call_${out.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: fn.name, arguments: argumentsText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** One transport attempt, classified as success / retryable / fatal. */
|
/** One transport attempt, classified as success / retryable / fatal. */
|
||||||
interface Attempt {
|
interface Attempt {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -161,6 +248,7 @@ interface Attempt {
|
|||||||
retryable: boolean;
|
retryable: boolean;
|
||||||
content?: string;
|
content?: string;
|
||||||
finishReason?: string | null;
|
finishReason?: string | null;
|
||||||
|
toolCalls?: ToolCallSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||||
@@ -179,7 +267,7 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One POST to /chat/completions. Classifies the outcome:
|
* One POST to /chat/completions. Classifies the outcome:
|
||||||
* - success → content + finish_reason
|
* - success → content and/or tool_calls
|
||||||
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
|
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
|
||||||
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
|
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
|
||||||
*/
|
*/
|
||||||
@@ -213,12 +301,27 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
choices?: {
|
choices?: {
|
||||||
finish_reason?: string | null;
|
finish_reason?: string | null;
|
||||||
message?: { content?: string; reasoning?: string | null };
|
message?: { content?: string; reasoning?: string | null; tool_calls?: unknown };
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
const choice = body.choices?.[0];
|
||||||
|
const message = choice?.message;
|
||||||
|
// A tool-call round is a SUCCESS even though `content` is null there: the
|
||||||
|
// model is asking for data, not failing (never treat it as truncation).
|
||||||
|
const toolCalls = parseToolCalls(message?.tool_calls);
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
retryable: false,
|
||||||
|
content:
|
||||||
|
typeof message?.content === 'string' && message.content !== '' ? message.content : undefined,
|
||||||
|
finishReason: choice?.finish_reason ?? null,
|
||||||
|
toolCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
// The reasoning model can exhaust its output budget on the CoT and return
|
// 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.
|
// a null `content` — treat that as a retryable truncation, not a success.
|
||||||
const content = body.choices?.[0]?.message?.content;
|
const content = message?.content;
|
||||||
if (typeof content !== 'string' || content === '') {
|
if (typeof content !== 'string' || content === '') {
|
||||||
return {
|
return {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
@@ -230,11 +333,16 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
status: res.status,
|
status: res.status,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
content,
|
content,
|
||||||
finishReason: body.choices?.[0]?.finish_reason ?? null,
|
finishReason: choice?.finish_reason ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
/** Build one /chat/completions request body. `tools` is only included when non-empty, so plain chat() keeps its exact legacy shape. */
|
||||||
|
function buildPayload(
|
||||||
|
messages: WireMessage[],
|
||||||
|
tools: ToolDefinition[] | undefined,
|
||||||
|
opts: ChatOptions,
|
||||||
|
): Record<string, unknown> {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
@@ -245,7 +353,16 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
};
|
};
|
||||||
if (opts.temperature != null) payload.temperature = opts.temperature;
|
if (opts.temperature != null) payload.temperature = opts.temperature;
|
||||||
if (sendEffort) payload.reasoning_effort = effort;
|
if (sendEffort) payload.reasoning_effort = effort;
|
||||||
|
if (tools && tools.length > 0) payload.tools = tools;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request with the standard retry/backoff: up to MAX_ATTEMPTS per
|
||||||
|
* request; resolves with a successful Attempt (content and/or toolCalls) or
|
||||||
|
* throws the last error once the attempts are spent.
|
||||||
|
*/
|
||||||
|
async function requestWithRetry(payload: Record<string, unknown>): Promise<Attempt> {
|
||||||
const totalStart = Date.now();
|
const totalStart = Date.now();
|
||||||
let lastStatus: number | undefined;
|
let lastStatus: number | undefined;
|
||||||
let lastError: Error = new Error('chat failed: no attempt completed');
|
let lastError: Error = new Error('chat failed: no attempt completed');
|
||||||
@@ -255,12 +372,13 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
if (out.status !== undefined) lastStatus = out.status;
|
if (out.status !== undefined) lastStatus = out.status;
|
||||||
if (out.error) lastError = out.error;
|
if (out.error) lastError = out.error;
|
||||||
|
|
||||||
if (out.content !== undefined) {
|
if (out.content !== undefined || out.toolCalls !== undefined) {
|
||||||
console.log('[llm] chat ok', {
|
console.log('[llm] chat ok', {
|
||||||
ms: Date.now() - totalStart,
|
ms: Date.now() - totalStart,
|
||||||
finishReason: out.finishReason ?? null,
|
finishReason: out.finishReason ?? null,
|
||||||
|
toolCalls: out.toolCalls?.length ?? 0,
|
||||||
});
|
});
|
||||||
return out.content;
|
return out;
|
||||||
}
|
}
|
||||||
// Give up on a non-retryable failure, or when the last attempt is spent.
|
// Give up on a non-retryable failure, or when the last attempt is spent.
|
||||||
if (!out.retryable || attempt === MAX_ATTEMPTS) {
|
if (!out.retryable || attempt === MAX_ATTEMPTS) {
|
||||||
@@ -278,6 +396,68 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||||
|
const out = await requestWithRetry(buildPayload(messages, undefined, opts));
|
||||||
|
return out.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function-calling loop — see the file header. Bounded: at most
|
||||||
|
* `maxRounds` tool rounds, then one final no-tools request forces an answer.
|
||||||
|
* Throws like chat() only on transport-level exhaustion (the engine maps that
|
||||||
|
* to a 502; bad *content* never throws, it is parsed defensively downstream).
|
||||||
|
*/
|
||||||
|
async function chatWithTools(
|
||||||
|
initial: WireMessage[],
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
executeTool: ToolExecutor,
|
||||||
|
opts: ChatOptions & { maxRounds?: number } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const maxRounds = Math.max(1, opts.maxRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
|
||||||
|
const messages: WireMessage[] = [...initial];
|
||||||
|
for (let round = 0; round < maxRounds; round++) {
|
||||||
|
const out = await requestWithRetry(buildPayload(messages, tools, opts));
|
||||||
|
// Tool calls win over any partial content: the model asked for data
|
||||||
|
// before it finished.
|
||||||
|
if (out.toolCalls && out.toolCalls.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: out.content ?? null,
|
||||||
|
tool_calls: out.toolCalls,
|
||||||
|
});
|
||||||
|
for (const tc of out.toolCalls) {
|
||||||
|
let result: string;
|
||||||
|
try {
|
||||||
|
const parsed: unknown =
|
||||||
|
tc.function.arguments !== '' ? JSON.parse(tc.function.arguments) : {};
|
||||||
|
const args =
|
||||||
|
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
result = executeTool(tc.function.name, args);
|
||||||
|
} catch (err) {
|
||||||
|
// Malformed arguments are not fatal: hand the model an error
|
||||||
|
// result so it can re-call with valid arguments or move on.
|
||||||
|
result = JSON.stringify({
|
||||||
|
error: `invalid tool arguments: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (out.content !== undefined) return out.content;
|
||||||
|
}
|
||||||
|
// The tool budget is spent without a final answer: one last no-tools
|
||||||
|
// request forces the model to commit.
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: 'You have reached your tool budget. Answer now with the final result only.',
|
||||||
|
});
|
||||||
|
const out = await requestWithRetry(buildPayload(messages, undefined, opts));
|
||||||
|
return out.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function listModels(): Promise<string[]> {
|
async function listModels(): Promise<string[]> {
|
||||||
const res = await transport(`${apiBase}/models`, {
|
const res = await transport(`${apiBase}/models`, {
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
@@ -287,5 +467,5 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
return (body.data ?? []).map((m) => m.id);
|
return (body.data ?? []).map((m) => m.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { chat, listModels };
|
return { chat, chatWithTools, listModels };
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-8
@@ -1,8 +1,24 @@
|
|||||||
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,
|
||||||
|
NO_MANS_LAND_FILL,
|
||||||
|
NO_MANS_LAND_STROKE,
|
||||||
|
OWNED_STROKE,
|
||||||
|
regionFillStyle,
|
||||||
|
regionOwner,
|
||||||
|
UNOWNED_FILL,
|
||||||
|
UNOWNED_STROKE,
|
||||||
|
unownedTooltipLabel,
|
||||||
|
type RegionMap,
|
||||||
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
|
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
|
||||||
|
// by region key (one per owned sub-region), not by a parent-country ISO_A3. The
|
||||||
|
// keys below are deliberately fake (obviously not real manifest entries) so this
|
||||||
|
// test stays self-contained and format-agnostic.
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
id: 'g',
|
id: 'g',
|
||||||
presetId: 'p',
|
presetId: 'p',
|
||||||
@@ -16,7 +32,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: ['albania-aaaa', 'wales-bbbb'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -24,23 +40,132 @@ 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: ['brandenburg-cccc'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
||||||
|
regionStats: {},
|
||||||
|
// (v0.33.0, #56) the map is ownership-only; wars are unused here.
|
||||||
|
wars: [],
|
||||||
|
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) the map is ownership-only; industry is unused here.
|
||||||
|
industry: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The RegionMap built from the fixture: the authoritative region key → 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 owned region key 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['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||||
// a region nobody owns is simply absent
|
// a region nobody owns is simply absent
|
||||||
expect(m.FRA).toBeUndefined();
|
expect(m['normandie-dddd']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.22 #16 P3) a released (no-man\'s-land) key — in GameState.unowned, owned by no nation — is absent: absence IS unowned', () => {
|
||||||
|
const s: GameState = JSON.parse(JSON.stringify(state));
|
||||||
|
// simulate a mid-game release: the key left every territory and entered unowned
|
||||||
|
s.nations.uk.territory = s.nations.uk.territory.filter((k) => k !== 'albania-aaaa');
|
||||||
|
s.unowned = ['albania-aaaa'];
|
||||||
|
const m = buildRegionMap(s);
|
||||||
|
expect(m['albania-aaaa']).toBeUndefined(); // the map cannot tell which kind of unowned
|
||||||
|
expect(s.unowned).toEqual(['albania-aaaa']); // ...the client's GameState.unowned decides that
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('regionOwner', () => {
|
||||||
|
it('resolves an owned region key to its owner record', () => {
|
||||||
|
expect(regionOwner(regions, 'albania-aaaa')).toEqual({
|
||||||
|
color: nationColor('uk'),
|
||||||
|
owner: 'UK',
|
||||||
|
ownerId: 'uk',
|
||||||
|
});
|
||||||
|
expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({
|
||||||
|
color: nationColor('de'),
|
||||||
|
owner: 'DE',
|
||||||
|
ownerId: 'de',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||||
|
expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an empty or missing key', () => {
|
||||||
|
expect(regionOwner(regions, '')).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, null)).toBeUndefined();
|
||||||
|
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #6 regression guard (sub-national map, region-keyed). The click/hover handlers
|
||||||
|
* resolve the owning nation from a rendered map feature. MapLibre repacks a
|
||||||
|
* GeoJSON source's top-level `id` into a *numeric index* when it stores the
|
||||||
|
* source, so a feature the map hands back at interaction time has a number for
|
||||||
|
* `id`. The choropleth is sub-national: a feature's own sub-region id is a code
|
||||||
|
* (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`),
|
||||||
|
* which the client stamps into `properties.regionKey` from `region-keys.json`
|
||||||
|
* (the RegionMap is keyed by that key, never the sub-region id nor the repacked
|
||||||
|
* id). This pins the exact data shape and proves the resolution:
|
||||||
|
* `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths
|
||||||
|
* (the repacked numeric `feature.id`, and the sub-region `regionId`, which is
|
||||||
|
* not a RegionMap key) do not.
|
||||||
|
*/
|
||||||
|
describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / repacked feature.id', () => {
|
||||||
|
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
||||||
|
// is a number (repacked), and the stable identifiers live only in properties —
|
||||||
|
// `regionId` (the sub-region code) and `regionKey` (the region key).
|
||||||
|
const repackedFeature = {
|
||||||
|
id: 0,
|
||||||
|
properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('resolves the owner from properties.regionKey (the fix)', () => {
|
||||||
|
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||||
|
color: nationColor('de'),
|
||||||
|
owner: 'DE',
|
||||||
|
ownerId: 'de',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
|
||||||
|
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
|
||||||
|
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
|
||||||
|
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
|
||||||
|
// reported "No owner" even for owned regions.
|
||||||
|
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapFeatureOwner', () => {
|
||||||
|
it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => {
|
||||||
|
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
||||||
|
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
||||||
|
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
||||||
|
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
||||||
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a region key nobody owns', () => {
|
||||||
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined();
|
||||||
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,3 +177,48 @@ describe('nationColor', () => {
|
|||||||
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// (v0.22, #16 P3) The no-man's-land presentation contract lives in
|
||||||
|
// shared/protocol (shared/*.test.ts is not in the vitest glob, so these
|
||||||
|
// tests live here): the distinct visible grey, the faint preset-unassigned
|
||||||
|
// look, the per-kind borders, and the tooltip sub-line.
|
||||||
|
describe('no man\'s land presentation contract (v0.22 #16 P3)', () => {
|
||||||
|
it('regionFillStyle: an owned region uses its nation color + the owned border', () => {
|
||||||
|
expect(regionFillStyle(regions['albania-aaaa'], false)).toEqual({
|
||||||
|
fill: nationColor('uk'),
|
||||||
|
stroke: OWNED_STROKE,
|
||||||
|
});
|
||||||
|
expect(regionFillStyle(regions['brandenburg-cccc'], true)).toEqual({
|
||||||
|
fill: nationColor('de'),
|
||||||
|
stroke: OWNED_STROKE,
|
||||||
|
}); // the no-man's-land flag never affects an owned region
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regionFillStyle: an unowned region that IS no man\'s land gets the distinct visible grey', () => {
|
||||||
|
expect(regionFillStyle(undefined, true)).toEqual({
|
||||||
|
fill: NO_MANS_LAND_FILL,
|
||||||
|
stroke: NO_MANS_LAND_STROKE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regionFillStyle: an unowned region that was never released keeps the faint look', () => {
|
||||||
|
expect(regionFillStyle(undefined, false)).toEqual({
|
||||||
|
fill: UNOWNED_FILL,
|
||||||
|
stroke: UNOWNED_STROKE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the no-man\'s-land grey can never collide with a nation color (hue-less vs HSL)', () => {
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toMatch(/^hsl\(/);
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('uk'));
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('de'));
|
||||||
|
// and it must be visibly different from the faint preset-unassigned fill
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toBe(UNOWNED_FILL);
|
||||||
|
expect(NO_MANS_LAND_STROKE).not.toBe(UNOWNED_STROKE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unownedTooltipLabel: the tooltip sub-line names the two kinds of unowned', () => {
|
||||||
|
expect(unownedTooltipLabel(true)).toBe("No man's land");
|
||||||
|
expect(unownedTooltipLabel(false)).toBe('No owner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+14
-5
@@ -1,6 +1,15 @@
|
|||||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
// Build the choropleth payload from a GameState: every region key (see
|
||||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
// public/region-keys.json) maps to its owning nation plus that nation's stable
|
||||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
// color (see shared.nationColor). The client merges this onto the sub-national
|
||||||
|
// regions.geo.json (resolving each feature by its own region key) and calls
|
||||||
|
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
|
||||||
|
//
|
||||||
|
// A key ABSENT from the returned map is unowned (no nation's territory holds
|
||||||
|
// it) — this covers both regions a preset never assigned and regions released
|
||||||
|
// to no man's land mid-game (v0.22, #16 P3). The map deliberately does NOT
|
||||||
|
// distinguish the two: the visible no-man's-land grey vs the faint
|
||||||
|
// preset-unassigned look is decided by the client from `GameState.unowned`
|
||||||
|
// (see shared/protocol.ts's regionFillStyle).
|
||||||
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 +17,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 key of nation.territory) {
|
||||||
map[code] = { color, owner: nation.name, ownerId: id };
|
map[key] = { color, owner: nation.name, ownerId: id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
canConquer,
|
||||||
|
CONQUEST_MILITARY_MARGIN,
|
||||||
|
deriveNationResources,
|
||||||
|
deriveNationStats,
|
||||||
|
migrateRegionStats,
|
||||||
|
seedRegionStats,
|
||||||
|
weightedMedian,
|
||||||
|
} from '../shared/protocol';
|
||||||
|
import type { GameState, Nation, NationStats } from '../shared/types';
|
||||||
|
|
||||||
|
// --- Test fixtures -----------------------------------------------------------
|
||||||
|
|
||||||
|
const S = (military: number, economy: number, stability: number, population: number): NationStats => ({
|
||||||
|
military,
|
||||||
|
economy,
|
||||||
|
stability,
|
||||||
|
population,
|
||||||
|
});
|
||||||
|
|
||||||
|
function nation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
|
||||||
|
return { id, name, type: 'major', stats, territory, relations: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkState(
|
||||||
|
nations: Record<string, Nation>,
|
||||||
|
regionStats?: Record<string, unknown>,
|
||||||
|
): GameState {
|
||||||
|
const base: GameState = {
|
||||||
|
id: 'g',
|
||||||
|
presetId: 'p',
|
||||||
|
presetTitle: 'P',
|
||||||
|
playerNationId: Object.keys(nations)[0] ?? '',
|
||||||
|
turn: 1,
|
||||||
|
date: '1939-09-01',
|
||||||
|
nations,
|
||||||
|
events: [],
|
||||||
|
pending: [],
|
||||||
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
regionStats: {} as GameState['regionStats'],
|
||||||
|
wars: [],
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
industry: [],
|
||||||
|
};
|
||||||
|
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||||
|
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- weightedMedian ----------------------------------------------------------
|
||||||
|
|
||||||
|
describe('weightedMedian', () => {
|
||||||
|
it('returns 0 for an empty input or zero total weight', () => {
|
||||||
|
expect(weightedMedian([], [])).toBe(0);
|
||||||
|
expect(weightedMedian([5, 6], [0, 0])).toBe(0); // zero weights
|
||||||
|
expect(weightedMedian([5, 6], [NaN, -1])).toBe(0); // non-finite / non-positive → 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the single value when it is the only weighted input', () => {
|
||||||
|
expect(weightedMedian([42], [7])).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the median of a set of IDENTICAL values is that value (translation-invariant)', () => {
|
||||||
|
// The invariant seeding + legacy migration rely on: equal values → that value,
|
||||||
|
// regardless of the (arbitrary) weights.
|
||||||
|
expect(weightedMedian([7, 7, 7], [1, 5, 9])).toBe(7);
|
||||||
|
expect(weightedMedian([0, 0], [10, 20])).toBe(0);
|
||||||
|
expect(weightedMedian([100, 100, 100], [1, 1, 1000])).toBe(100);
|
||||||
|
// and a constant shift shifts the result by the same constant
|
||||||
|
expect(weightedMedian([107, 107, 107], [1, 5, 9])).toBe(weightedMedian([7, 7, 7], [1, 5, 9]) + 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the value at the first point the cumulative weight reaches half the total', () => {
|
||||||
|
// two equal weights: the lower value is reached first (cum 1 of 2 ≥ 1)
|
||||||
|
expect(weightedMedian([10, 20], [1, 1])).toBe(10);
|
||||||
|
// an odd middle: the true middle value
|
||||||
|
expect(weightedMedian([10, 20, 30], [1, 1, 1])).toBe(20);
|
||||||
|
// the heavier value dominates
|
||||||
|
expect(weightedMedian([10, 20], [1, 3])).toBe(20);
|
||||||
|
expect(weightedMedian([10, 20], [3, 1])).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats non-finite or non-positive weights as excluded (0)', () => {
|
||||||
|
// NaN weight on 10, positive on 20 → only 20 counts
|
||||||
|
expect(weightedMedian([10, 20], [NaN, 1])).toBe(20);
|
||||||
|
// negative weight on 20, positive on 10 → only 10 counts
|
||||||
|
expect(weightedMedian([10, 20], [1, -5])).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- deriveNationStats -------------------------------------------------------
|
||||||
|
|
||||||
|
describe('deriveNationStats', () => {
|
||||||
|
const regionStats = {
|
||||||
|
a: S(50, 50, 50, 50),
|
||||||
|
b: S(90, 90, 90, 90),
|
||||||
|
};
|
||||||
|
const geo: Record<string, { area: number; population: number }> = { a: { area: 100, population: 1000 }, b: { area: 1000, population: 1000 } };
|
||||||
|
|
||||||
|
it('weights by area×population: the larger region dominates the aggregate', () => {
|
||||||
|
// a: weight 100×1000=100000, b: weight 1000×1000=1000000 → b (90) dominates
|
||||||
|
const out = deriveNationStats(['a', 'b'], regionStats, (k) => geo[k]);
|
||||||
|
expect(out).toEqual(S(90, 90, 90, 90));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to a uniform (unweighted) median without geo: the lower value is reached first', () => {
|
||||||
|
// equal weights 1,1 → the lower (a=50) is reached at half-total first
|
||||||
|
const out = deriveNationStats(['a', 'b'], regionStats);
|
||||||
|
expect(out).toEqual(S(50, 50, 50, 50));
|
||||||
|
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||||
|
const out2 = deriveNationStats(['a', 'b'], regionStats, () => undefined);
|
||||||
|
expect(out2).toEqual(S(50, 50, 50, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a region key that has no stats entry', () => {
|
||||||
|
// b has no entry → only a is counted → a's (50) values
|
||||||
|
const out = deriveNationStats(['a', 'b'], { a: S(50, 50, 50, 50) }, (k) => geo[k]);
|
||||||
|
expect(out).toEqual(S(50, 50, 50, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zero stats when none of the owned regions has a stats entry', () => {
|
||||||
|
const out = deriveNationStats(['a', 'b'], {}, (k) => geo[k]);
|
||||||
|
expect(out).toEqual(S(0, 0, 0, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single region yields exactly that region\'s stats (the seeding invariant)', () => {
|
||||||
|
expect(deriveNationStats(['a'], regionStats, (k) => geo[k])).toEqual(S(50, 50, 50, 50));
|
||||||
|
expect(deriveNationStats(['a'], regionStats)).toEqual(S(50, 50, 50, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||||
|
// a has area 0 (weight falls back to 1×pop), b has full weight → b dominates
|
||||||
|
const g: Record<string, { area: number; population: number }> = { a: { area: 0, population: 1000 }, b: { area: 1000, population: 1000 } };
|
||||||
|
const out = deriveNationStats(['a', 'b'], regionStats, (k) => g[k]);
|
||||||
|
expect(out).toEqual(S(90, 90, 90, 90));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- deriveNationResources ---------------------------------------------------
|
||||||
|
|
||||||
|
describe('deriveNationResources (v0.36.0 #59)', () => {
|
||||||
|
const prof: Record<string, { oil: number; iron: number; food: number; industry: number }> = {
|
||||||
|
a: { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 },
|
||||||
|
b: { oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8 },
|
||||||
|
};
|
||||||
|
const geo: Record<string, { area: number; population: number }> = {
|
||||||
|
a: { area: 100, population: 1000 },
|
||||||
|
b: { area: 1000, population: 1000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('weights by area×population: the larger region dominates the average', () => {
|
||||||
|
// a: weight 100×1000=100000, b: weight 1000×1000=1000000
|
||||||
|
// → (0.2×1e5 + 0.8×1e6)/1.1e6 = 82/110 = 0.7454… → 0.75 on every axis
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => geo[k])).toEqual({
|
||||||
|
oil: 0.75, iron: 0.75, food: 0.75, industry: 0.75,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to a uniform (unweighted) average without geo: equal weights', () => {
|
||||||
|
// equal weights 1,1 → the true mean 0.5 on every axis
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k])).toEqual({
|
||||||
|
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||||
|
});
|
||||||
|
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k], () => undefined)).toEqual({
|
||||||
|
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a region key that has no resource-profile entry', () => {
|
||||||
|
// b has no profile → only a is counted → a's values
|
||||||
|
const out = deriveNationResources(['a', 'b'], (k) => (k === 'a' ? prof.a : undefined), (k) => geo[k]);
|
||||||
|
expect(out).toEqual(prof.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the zero profile when none of the owned regions has a profile', () => {
|
||||||
|
expect(deriveNationResources(['a', 'b'], () => undefined)).toEqual({
|
||||||
|
oil: 0, iron: 0, food: 0, industry: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single region yields exactly that region\'s profile', () => {
|
||||||
|
expect(deriveNationResources(['a'], (k) => prof[k], (k) => geo[k])).toEqual(prof.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||||
|
// a: area 0 → weight 1×1000=1000; b: full weight 1e6 → b dominates
|
||||||
|
const g: Record<string, { area: number; population: number }> = {
|
||||||
|
a: { area: 0, population: 1000 },
|
||||||
|
b: { area: 1000, population: 1000 },
|
||||||
|
};
|
||||||
|
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => g[k])).toEqual({
|
||||||
|
oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds each axis to 2 decimal places', () => {
|
||||||
|
// a=0.2 (w=1×1=1) + b=0.4 (w=2×1=2) → (0.2×1 + 0.4×2)/(1+2) = 1.0/3 = 0.3333… → 0.33
|
||||||
|
const g: Record<string, { area: number; population: number }> = {
|
||||||
|
a: { area: 1, population: 1 },
|
||||||
|
b: { area: 2, population: 1 },
|
||||||
|
};
|
||||||
|
const out = deriveNationResources(
|
||||||
|
['a', 'b'],
|
||||||
|
(k) => k === 'a'
|
||||||
|
? { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 }
|
||||||
|
: { oil: 0.4, iron: 0.4, food: 0.4, industry: 0.4 },
|
||||||
|
(k) => g[k],
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ oil: 0.33, iron: 0.33, food: 0.33, industry: 0.33 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- seedRegionStats ---------------------------------------------------------
|
||||||
|
|
||||||
|
describe('seedRegionStats', () => {
|
||||||
|
it('copies each owner\'s stats to every region it owns', () => {
|
||||||
|
const nations = {
|
||||||
|
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2']),
|
||||||
|
de: nation('de', 'DE', S(95, 70, 75, 90), ['k3']),
|
||||||
|
};
|
||||||
|
expect(seedRegionStats(nations)).toEqual({
|
||||||
|
k1: S(80, 70, 60, 50),
|
||||||
|
k2: S(80, 70, 60, 50),
|
||||||
|
k3: S(95, 70, 75, 90),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the first owner\'s stats a key owned by two or more nations (corrupt)', () => {
|
||||||
|
const nations = {
|
||||||
|
a: nation('a', 'A', S(10, 10, 10, 10), ['k1']),
|
||||||
|
b: nation('b', 'B', S(90, 90, 90, 90), ['k1']),
|
||||||
|
};
|
||||||
|
expect(seedRegionStats(nations)).toEqual({ k1: S(10, 10, 10, 10) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty table for nations with no territory', () => {
|
||||||
|
const nations = { a: nation('a', 'A', S(10, 10, 10, 10), []) };
|
||||||
|
expect(seedRegionStats(nations)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- migrateRegionStats ------------------------------------------------------
|
||||||
|
|
||||||
|
describe('migrateRegionStats', () => {
|
||||||
|
it('returns a v0.28+ row (regionStats present) UNCHANGED — same reference, no recompute', () => {
|
||||||
|
const state = mkState(
|
||||||
|
{ uk: nation('uk', 'UK', S(42, 70, 60, 50), ['k1']) },
|
||||||
|
{ k1: S(99, 70, 60, 50) }, // deliberately divergent from the nation stats
|
||||||
|
);
|
||||||
|
const out = migrateRegionStats(state);
|
||||||
|
expect(out).toBe(state); // same reference: untouched
|
||||||
|
expect(out.regionStats).toEqual({ k1: S(99, 70, 60, 50) });
|
||||||
|
expect(out.nations.uk.stats).toEqual(S(42, 70, 60, 50)); // NOT recomputed from the region
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates a legacy row (regionStats absent): seeds each owned region and preserves stats', () => {
|
||||||
|
const state = mkState({
|
||||||
|
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2', 'k3']),
|
||||||
|
de: nation('de', 'DE', S(95, 70, 75, 90), ['k4']),
|
||||||
|
});
|
||||||
|
// sanity: we built a legacy row
|
||||||
|
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
|
||||||
|
|
||||||
|
const out = migrateRegionStats(state, (k) =>
|
||||||
|
k === 'k1' ? { area: 100, population: 1000 }
|
||||||
|
: k === 'k2' ? { area: 1, population: 1 }
|
||||||
|
: k === 'k3' ? { area: 9, population: 9 }
|
||||||
|
: { area: 50, population: 500 });
|
||||||
|
// each owned region is seeded with a copy of its owner's stats…
|
||||||
|
expect(out.regionStats).toEqual({
|
||||||
|
k1: S(80, 70, 60, 50),
|
||||||
|
k2: S(80, 70, 60, 50),
|
||||||
|
k3: S(80, 70, 60, 50),
|
||||||
|
k4: S(95, 70, 75, 90),
|
||||||
|
});
|
||||||
|
// …and the (multi-region) nation's stats are PRESERVED: the weighted median of
|
||||||
|
// identical values is that value, regardless of the (uneven) weights.
|
||||||
|
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50));
|
||||||
|
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90));
|
||||||
|
// the original row is NOT mutated
|
||||||
|
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
|
||||||
|
expect(state.nations.uk.stats).toEqual(S(80, 70, 60, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a legacy 0-region nation keeps its stats (no recompute, no entries)', () => {
|
||||||
|
const state = mkState({
|
||||||
|
uk: nation('uk', 'UK', S(80, 70, 60, 50), []),
|
||||||
|
de: nation('de', 'DE', S(95, 70, 75, 90), ['k1']),
|
||||||
|
});
|
||||||
|
const out = migrateRegionStats(state);
|
||||||
|
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50)); // unchanged
|
||||||
|
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90)); // single region → preserved
|
||||||
|
expect(out.regionStats).toEqual({ k1: S(95, 70, 75, 90) });
|
||||||
|
expect('uk' in (out.regionStats ?? {})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- canConquer (v0.30, #50: the military hard gate) -------------------------
|
||||||
|
|
||||||
|
describe('canConquer', () => {
|
||||||
|
it('the default margin constant is 15', () => {
|
||||||
|
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an attacker within the margin of the defender proceeds (gap == margin)', () => {
|
||||||
|
// attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below)
|
||||||
|
expect(canConquer(10, 25)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => {
|
||||||
|
// attacker 9, defender 25 → gap of 16 > margin → repel
|
||||||
|
expect(canConquer(9, 25)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an equal attacker proceeds', () => {
|
||||||
|
expect(canConquer(50, 50)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an attacker within the margin (gap < margin) proceeds', () => {
|
||||||
|
expect(canConquer(30, 40)).toBe(true); // gap 10
|
||||||
|
expect(canConquer(14, 25)).toBe(true); // gap 11
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an attacker ahead of the defender always proceeds', () => {
|
||||||
|
expect(canConquer(90, 10)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => {
|
||||||
|
expect(canConquer(50, 50, 0)).toBe(true); // equal
|
||||||
|
expect(canConquer(51, 50, 0)).toBe(true); // ahead
|
||||||
|
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
allTerritoryKeys,
|
||||||
|
centroidOf,
|
||||||
|
expandCountriesToKeys,
|
||||||
|
geoOf,
|
||||||
|
hasAdjacency,
|
||||||
|
hasCentroids,
|
||||||
|
hasGeo,
|
||||||
|
hasRegionData,
|
||||||
|
hasResources,
|
||||||
|
loadRegions,
|
||||||
|
neighboursOf,
|
||||||
|
referenceForKeys,
|
||||||
|
regionName,
|
||||||
|
resetRegions,
|
||||||
|
resourceOf,
|
||||||
|
validKeys,
|
||||||
|
} from './regions';
|
||||||
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
|
// The repo root: this file lives in server/, so '..' is the directory holding
|
||||||
|
// public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors
|
||||||
|
// how server/index.ts locates its root via import.meta.url).
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
expect(loadRegions(root)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the full manifest: 1,800 unique valid region keys', () => {
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
|
||||||
|
const fra = expandCountriesToKeys(['FRA']);
|
||||||
|
// FRA has 42 sub-regions in the committed dataset.
|
||||||
|
expect(fra).toHaveLength(42);
|
||||||
|
// every expanded key is a real region key
|
||||||
|
for (const k of fra) expect(validKeys().has(k)).toBe(true);
|
||||||
|
// and the reference table labels each one with its parent country
|
||||||
|
const ref = referenceForKeys(fra);
|
||||||
|
expect(Object.keys(ref)).toHaveLength(42);
|
||||||
|
for (const [k, label] of Object.entries(ref)) {
|
||||||
|
expect(label.endsWith('(FRA)')).toBe(true);
|
||||||
|
expect(regionName(k)).toBeTruthy();
|
||||||
|
}
|
||||||
|
// sample known FRA keys, and their exact reference strings
|
||||||
|
expect(fra).toContain('ain-lqtt');
|
||||||
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
|
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands RUS to all 84 sub-regions after the #31 + #41 Arkhangel splits (no ownership gap at game init)', () => {
|
||||||
|
// #31 split RU-ARK into the mainland + New Earth + Severnaya Zemlya; #41
|
||||||
|
// repartitioned the mainland into the core (unchanged key) + Pechora +
|
||||||
|
// Yamal. A fresh game's RUS territory (expanded from the preset's
|
||||||
|
// country list) must contain all five sub-region keys — the main one
|
||||||
|
// (ownership-safe, unchanged key) plus the island and band ones.
|
||||||
|
const rus = expandCountriesToKeys(['RUS']);
|
||||||
|
expect(rus).toHaveLength(84);
|
||||||
|
expect(rus).toContain('arkhangel-sk-lhbk');
|
||||||
|
expect(rus).toContain('new-earth-eikt');
|
||||||
|
expect(rus).toContain('severnaya-zemlya-awqp');
|
||||||
|
expect(rus).toContain('pechora-qwhs');
|
||||||
|
expect(rus).toContain('yamal-gosw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('referenceForKeys labels only known keys and omits unknown ones', () => {
|
||||||
|
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
||||||
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
|
expect(ref['not-a-real-key-xyz']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => {
|
||||||
|
const state: GameState = {
|
||||||
|
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: expandCountriesToKeys(['GBR']),
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
id: 'fr',
|
||||||
|
name: 'FR',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
|
territory: expandCountriesToKeys(['FRA']),
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
pending: [],
|
||||||
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||||
|
regionStats: {},
|
||||||
|
wars: [],
|
||||||
|
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
// (v0.36.0, #59) unused here (allTerritoryKeys is ownership-only).
|
||||||
|
industry: [],
|
||||||
|
};
|
||||||
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAdjacency() is true: the region-adjacency manifest loaded (v0.21 #16 P2)', () => {
|
||||||
|
expect(hasAdjacency()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neighboursOf returns the union of bordering keys (deduped, sorted, queried excluded)', () => {
|
||||||
|
// known mutual pairs from the manifest (verified in the Stage 1 spot checks)
|
||||||
|
const moselle = neighboursOf(['moselle-rygy']);
|
||||||
|
expect(moselle).toContain('baden-wurttemberg-oyje'); // FRA/DEU border
|
||||||
|
expect(neighboursOf(['baden-wurttemberg-oyje'])).toContain('moselle-rygy'); // mutual
|
||||||
|
expect(neighboursOf(['acre-ekzw'])).toContain('loreto-ipkx'); // BRA/PER border
|
||||||
|
expect(neighboursOf(['loreto-ipkx'])).toContain('acre-ekzw'); // mutual
|
||||||
|
|
||||||
|
// union + dedupe + sorted + the queried keys themselves excluded
|
||||||
|
const union = neighboursOf(['moselle-rygy', 'baden-wurttemberg-oyje']);
|
||||||
|
expect(union).toEqual([...union].sort());
|
||||||
|
expect(new Set(union).size).toBe(union.length); // no duplicates
|
||||||
|
expect(union).not.toContain('moselle-rygy');
|
||||||
|
expect(union).not.toContain('baden-wurttemberg-oyje');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neighboursOf returns [] for unknown keys or an empty set', () => {
|
||||||
|
expect(neighboursOf(['not-a-real-key-xyz'])).toEqual([]);
|
||||||
|
expect(neighboursOf([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => {
|
||||||
|
expect(hasCentroids()).toBe(true);
|
||||||
|
const ain = centroidOf('ain-lqtt');
|
||||||
|
expect(ain).toBeDefined();
|
||||||
|
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||||
|
expect(ain!.lon).toBeCloseTo(4.8, 1);
|
||||||
|
expect(ain!.spread).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => {
|
||||||
|
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.28 #46) hasGeo() is true and geoOf resolves a real key to area + population', () => {
|
||||||
|
expect(hasGeo()).toBe(true);
|
||||||
|
const ain = geoOf('ain-lqtt');
|
||||||
|
expect(ain).toBeDefined();
|
||||||
|
expect(ain!.area).toBeGreaterThanOrEqual(0); // km²
|
||||||
|
expect(ain!.population).toBeGreaterThanOrEqual(0);
|
||||||
|
// exact spot-check values pinned from the committed manifest (Ain, FRA)
|
||||||
|
expect(ain!.area).toBeCloseTo(22557.77, 1); // km²
|
||||||
|
expect(ain!.population).toBe(2304126);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.28 #46) geoOf returns undefined for an unknown key', () => {
|
||||||
|
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.36 #59) hasResources() is true and resourceOf resolves a real key to a 4-value profile', () => {
|
||||||
|
expect(hasResources()).toBe(true);
|
||||||
|
// exact spot-check values pinned from the committed manifest (Ain, FRA)
|
||||||
|
expect(resourceOf('ain-lqtt')).toEqual({ oil: 0.94, iron: 0.92, food: 0.51, industry: 0.4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.36 #59) resourceOf returns undefined for an unknown key', () => {
|
||||||
|
expect(resourceOf('not-a-real-key-xyz')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('graceful degradation with no region data', () => {
|
||||||
|
beforeEach(() => resetRegions());
|
||||||
|
|
||||||
|
it('expands to the raw input, omits references, and reports no data', () => {
|
||||||
|
expect(hasRegionData()).toBe(false);
|
||||||
|
// no manifest -> the country list passes through unchanged (old model)
|
||||||
|
expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']);
|
||||||
|
expect(referenceForKeys(['FRA'])).toEqual({});
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
|
expect(regionName('FRA')).toBeUndefined();
|
||||||
|
// and no adjacency: the LLM tool / frontier field switch off
|
||||||
|
expect(hasAdjacency()).toBe(false);
|
||||||
|
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||||
|
// and no centroids: the marker-placement path degrades to a no-op
|
||||||
|
expect(hasCentroids()).toBe(false);
|
||||||
|
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||||
|
// and no region-geo: the per-region stats weighting degrades to uniform
|
||||||
|
expect(hasGeo()).toBe(false);
|
||||||
|
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||||
|
// and no region-resources: industry boosts use the unscaled base value
|
||||||
|
expect(hasResources()).toBe(false);
|
||||||
|
expect(resourceOf('ain-lqtt')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => resetRegions());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => {
|
||||||
|
const realRoot = root;
|
||||||
|
// A root that mimics the prod Docker image: no public/, the region files live
|
||||||
|
// in dist/ (where Vite copies public/* at build). This is the exact layout that
|
||||||
|
// broke v0.19.0 in prod (the loader only looked in public/).
|
||||||
|
const base = mkdtempSync(path.join(tmpdir(), 'history-regions-'));
|
||||||
|
const prodRoot = path.join(base, 'prod');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(path.join(prodRoot, 'dist'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-keys.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-keys.json'),
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'regions.geo.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'regions.geo.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => resetRegions());
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(base, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1800);
|
||||||
|
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||||
|
expect(loadRegions(realRoot)).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
||||||
|
// this fixture copies only region-keys.json + regions.geo.json (no
|
||||||
|
// region-adjacency.json) — the server must boot and serve turns with the
|
||||||
|
// tool/frontier simply switched off.
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasAdjacency()).toBe(false);
|
||||||
|
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||||
|
// and no centroids (not copied into this dist/) -> marker placement degrades
|
||||||
|
expect(hasCentroids()).toBe(false);
|
||||||
|
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||||
|
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
|
||||||
|
expect(hasGeo()).toBe(false);
|
||||||
|
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||||
|
// and no region-resources (not copied into this dist/) -> boosts unscaled
|
||||||
|
expect(hasResources()).toBe(false);
|
||||||
|
expect(resourceOf('ain-lqtt')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.36 #59) a dist/ WITH the region-resources manifest loads it (prod layout)', () => {
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-resources.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-resources.json'),
|
||||||
|
);
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasResources()).toBe(true);
|
||||||
|
expect(resourceOf('ain-lqtt')).toEqual({ oil: 0.94, iron: 0.92, food: 0.51, industry: 0.4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-centroids.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-centroids.json'),
|
||||||
|
);
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasCentroids()).toBe(true);
|
||||||
|
const ain = centroidOf('ain-lqtt');
|
||||||
|
expect(ain).toBeDefined();
|
||||||
|
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.28 #46) a dist/ WITH the region-geo manifest loads it (prod layout)', () => {
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-geo.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-geo.json'),
|
||||||
|
);
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasGeo()).toBe(true);
|
||||||
|
const ain = geoOf('ain-lqtt');
|
||||||
|
expect(ain).toBeDefined();
|
||||||
|
expect(ain!.area).toBeCloseTo(22557.77, 1);
|
||||||
|
expect(ain!.population).toBe(2304126);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
|
const emptyRoot = path.join(base, 'empty');
|
||||||
|
mkdirSync(emptyRoot, { recursive: true });
|
||||||
|
expect(loadRegions(emptyRoot)).toBe(false);
|
||||||
|
expect(hasRegionData()).toBe(false);
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||||
|
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
||||||
|
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
||||||
|
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
|
||||||
|
// bordering region keys, built by scripts/build-region-adjacency.mjs) and the
|
||||||
|
// precomputed region-centroid manifest (region-centroids.json: region key ->
|
||||||
|
// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all
|
||||||
|
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
|
||||||
|
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
|
||||||
|
// joins them into the lookups the region-level ownership model needs:
|
||||||
|
//
|
||||||
|
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions (v0.19, #16 P1)
|
||||||
|
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||||||
|
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
|
||||||
|
// - neighboursOf(): the union of the bordering keys for a set of keys — the
|
||||||
|
// data behind the LLM's getNeighbours tool and the player-frontier prompt
|
||||||
|
// field (v0.21, #16 P2)
|
||||||
|
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||||||
|
// — the data behind LLM-placed map markers (v0.27, #44)
|
||||||
|
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||||||
|
// — the weighting source for the per-region stats model (v0.28, #46)
|
||||||
|
// - resourceOf(): a region key's synthesized resource profile (from
|
||||||
|
// region-resources.json) — the data behind the LLM's industry lever
|
||||||
|
// (v0.36, #59)
|
||||||
|
//
|
||||||
|
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||||
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||||
|
// the tsc program and breaks WorldMap.tsx; see the v0.17 changelog note).
|
||||||
|
// Loaded once at boot; the accessors below are pure data reads thereafter.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { GameState, RegionResources } from '../shared/types';
|
||||||
|
|
||||||
|
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||||
|
interface RegionInfo {
|
||||||
|
name: string;
|
||||||
|
parent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegionData {
|
||||||
|
/** stable feature id -> region key (from public/region-keys.json). */
|
||||||
|
idToKey: Record<string, string>;
|
||||||
|
/** region key -> { name, parent }. */
|
||||||
|
keyToInfo: Record<string, RegionInfo>;
|
||||||
|
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
|
||||||
|
keysByCountry: Record<string, string[]>;
|
||||||
|
/** the set of all region keys. */
|
||||||
|
validKeys: Set<string>;
|
||||||
|
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
||||||
|
adjacency: Record<string, string[]>;
|
||||||
|
/** region key -> anchor point (from public/region-centroids.json). */
|
||||||
|
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||||
|
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||||||
|
geo: Record<string, { area: number; population: number }>;
|
||||||
|
/** region key -> resource profile (from public/region-resources.json, v0.36 #59). */
|
||||||
|
resources: Record<string, RegionResources>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: RegionData | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
|
||||||
|
* Idempotent.
|
||||||
|
* The files are served to the client from `public/` in dev (Vite serves it
|
||||||
|
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
|
||||||
|
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
|
||||||
|
* (plus the root as a last resort) and use the first directory that holds the
|
||||||
|
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
||||||
|
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
||||||
|
* report "no adjacency" (the LLM tool and the frontier prompt field switch
|
||||||
|
* off; everything else keeps working). The centroid manifest is likewise
|
||||||
|
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
|
||||||
|
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
|
||||||
|
* placed, but everything else keeps working). Returns true when the core pair loaded
|
||||||
|
* cleanly; a missing/broken pair clears the data and returns false so the
|
||||||
|
* server still boots and degrades gracefully (expansion becomes a no-op,
|
||||||
|
* ownership falls back to the raw input keys).
|
||||||
|
*/
|
||||||
|
export function loadRegions(root: string): boolean {
|
||||||
|
const keyFile = 'region-keys.json';
|
||||||
|
const geoFile = 'regions.geo.json';
|
||||||
|
const adjacencyFile = 'region-adjacency.json';
|
||||||
|
const centroidsFile = 'region-centroids.json';
|
||||||
|
const regionGeoFile = 'region-geo.json';
|
||||||
|
const regionResourcesFile = 'region-resources.json';
|
||||||
|
const base = [
|
||||||
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
|
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||||
|
root, // last resort
|
||||||
|
].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile)));
|
||||||
|
if (!base) {
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const keyPath = path.join(base, keyFile);
|
||||||
|
const geoPath = path.join(base, geoFile);
|
||||||
|
try {
|
||||||
|
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||||
|
const geoJson = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
|
};
|
||||||
|
const keyToInfo: Record<string, RegionInfo> = {};
|
||||||
|
const keysByCountry: Record<string, string[]> = {};
|
||||||
|
const validKeys = new Set<string>();
|
||||||
|
for (const f of geoJson.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
||||||
|
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
||||||
|
keyToInfo[key] = { name, parent };
|
||||||
|
validKeys.add(key);
|
||||||
|
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacency is optional: a missing file means "no adjacency data".
|
||||||
|
let adjacency: Record<string, string[]> = {};
|
||||||
|
const adjacencyPath = path.join(base, adjacencyFile);
|
||||||
|
if (existsSync(adjacencyPath)) {
|
||||||
|
try {
|
||||||
|
adjacency = JSON.parse(readFileSync(adjacencyPath, 'utf8')) as Record<string, string[]>;
|
||||||
|
} catch (err) {
|
||||||
|
// A corrupt manifest must not kill the core region data: degrade to
|
||||||
|
// "no adjacency" (the LLM tool / frontier guidance simply switch off).
|
||||||
|
console.error('[regions] failed to load region-adjacency.json, continuing without it', err);
|
||||||
|
adjacency = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centroids are optional too (v0.27, #44): a missing/broken file means
|
||||||
|
// "no centroid data" — new markers can't be placed (their adds are
|
||||||
|
// dropped), but everything else keeps working.
|
||||||
|
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
|
||||||
|
const centroidsPath = path.join(base, centroidsFile);
|
||||||
|
if (existsSync(centroidsPath)) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
{ lat?: unknown; lon?: unknown; spread?: unknown }
|
||||||
|
>;
|
||||||
|
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v.lat === 'number' &&
|
||||||
|
typeof v.lon === 'number' &&
|
||||||
|
typeof v.spread === 'number' &&
|
||||||
|
Number.isFinite(v.lat) &&
|
||||||
|
Number.isFinite(v.lon) &&
|
||||||
|
Number.isFinite(v.spread)
|
||||||
|
) {
|
||||||
|
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
|
||||||
|
centroids = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The region-geo manifest (v0.28, #46): area (km²) + population per region
|
||||||
|
// key — the weighting source for the per-region stats model. OPTIONAL like
|
||||||
|
// the others: a missing/broken file means "no geo data", and weighting
|
||||||
|
// degrades to uniform (every region weight 1) while everything else keeps
|
||||||
|
// working.
|
||||||
|
let geo: Record<string, { area: number; population: number }> = {};
|
||||||
|
const geoDataPath = path.join(base, regionGeoFile);
|
||||||
|
if (existsSync(geoDataPath)) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(geoDataPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
{ area?: unknown; population?: unknown }
|
||||||
|
>;
|
||||||
|
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v.area === 'number' &&
|
||||||
|
Number.isFinite(v.area) &&
|
||||||
|
v.area >= 0 &&
|
||||||
|
typeof v.population === 'number' &&
|
||||||
|
Number.isFinite(v.population) &&
|
||||||
|
v.population >= 0
|
||||||
|
) {
|
||||||
|
geo[k] = { area: v.area, population: v.population };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region-geo.json, continuing without it', err);
|
||||||
|
geo = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The region-resources manifest (v0.36, #59): a synthesized four-value
|
||||||
|
// resource profile per region key — the data behind the LLM's industry
|
||||||
|
// lever. OPTIONAL like the others: a missing/broken file means "no
|
||||||
|
// resource data" — industry boosts degrade to the unscaled base value and
|
||||||
|
// the prompt omits the aggregate profile — while everything else keeps
|
||||||
|
// working.
|
||||||
|
let resources: Record<string, RegionResources> = {};
|
||||||
|
const resourcesPath = path.join(base, regionResourcesFile);
|
||||||
|
if (existsSync(resourcesPath)) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(resourcesPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
{ oil?: unknown; iron?: unknown; food?: unknown; industry?: unknown }
|
||||||
|
>;
|
||||||
|
// Keep only well-formed entries (all four values finite numbers in
|
||||||
|
// [0, 1]); a corrupt file degrades to "none".
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v.oil === 'number' && Number.isFinite(v.oil) && v.oil >= 0 && v.oil <= 1 &&
|
||||||
|
typeof v.iron === 'number' && Number.isFinite(v.iron) && v.iron >= 0 && v.iron <= 1 &&
|
||||||
|
typeof v.food === 'number' && Number.isFinite(v.food) && v.food >= 0 && v.food <= 1 &&
|
||||||
|
typeof v.industry === 'number' && Number.isFinite(v.industry) && v.industry >= 0 && v.industry <= 1
|
||||||
|
) {
|
||||||
|
resources[k] = { oil: v.oil, iron: v.iron, food: v.food, industry: v.industry };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region-resources.json, continuing without it', err);
|
||||||
|
resources = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo, resources };
|
||||||
|
return validKeys.size > 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region data', err);
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */
|
||||||
|
export function resetRegions(): void {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the manifest + geo loaded cleanly. */
|
||||||
|
export function hasRegionData(): boolean {
|
||||||
|
return data !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a nation's ISO_A3 country list to the region keys of all its sub-regions
|
||||||
|
* (file order, deduped). This is what turns a preset's country-level territory
|
||||||
|
* into region-level ownership at game creation. Returns the input unchanged when
|
||||||
|
* no region data is loaded (graceful degradation — behaves like the old model).
|
||||||
|
*/
|
||||||
|
export function expandCountriesToKeys(countries: string[]): string[] {
|
||||||
|
if (!data) return countries;
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of countries) {
|
||||||
|
for (const key of data.keysByCountry[c] ?? []) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
|
||||||
|
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
|
||||||
|
* passes the game's owned-region universe) so the prompt stays bounded.
|
||||||
|
*/
|
||||||
|
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!data) return out;
|
||||||
|
for (const key of keys) {
|
||||||
|
const info = data.keyToInfo[key];
|
||||||
|
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The region key's human name (for the dossier), or undefined if unknown. */
|
||||||
|
export function regionName(key: string): string | undefined {
|
||||||
|
return data?.keyToInfo[key]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The union of every nation's territory region keys (the game's region universe). */
|
||||||
|
export function allTerritoryKeys(state: GameState): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const n of Object.values(state.nations)) {
|
||||||
|
for (const key of n.territory) out.add(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
|
||||||
|
export function validKeys(): ReadonlySet<string> {
|
||||||
|
return data ? data.validKeys : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of ISO_A3 country codes that own at least one sub-region in the
|
||||||
|
* manifest (the valid `territory` codes for a world draft, v0.31 #52). These
|
||||||
|
* are the keys of `keysByCountry` — i.e. the distinct `properties.parent`
|
||||||
|
* values in regions.geo.json. Empty when no region data is loaded; callers
|
||||||
|
* pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a
|
||||||
|
* draft's territory is checked against real countries.
|
||||||
|
*/
|
||||||
|
export function validCountryCodes(): ReadonlySet<string> {
|
||||||
|
return data ? new Set(Object.keys(data.keysByCountry)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the region-adjacency manifest loaded with at least one entry.
|
||||||
|
* Callers (the server's jump route) use this to decide whether to hand the
|
||||||
|
* engine an adjacency resolver (which turns on the LLM's getNeighbours tool
|
||||||
|
* and the frontier prompt field) or fall back to the plain chat() path.
|
||||||
|
*/
|
||||||
|
export function hasAdjacency(): boolean {
|
||||||
|
return data !== null && Object.keys(data.adjacency).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the region-centroid manifest loaded with at least one entry
|
||||||
|
* (v0.27, #44). The server's jump route uses this to decide whether to hand
|
||||||
|
* the engine a centroid resolver (which lets a turn place new map markers)
|
||||||
|
* or skip marker placement (existing markers are still maintained).
|
||||||
|
*/
|
||||||
|
export function hasCentroids(): boolean {
|
||||||
|
return data !== null && Object.keys(data.centroids).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the region-geo manifest loaded with at least one entry (v0.28,
|
||||||
|
* #46). The server's jump route uses this to decide whether to hand the
|
||||||
|
* engine a geo resolver (which weights the per-region stats derivation by
|
||||||
|
* area×population) or fall back to a uniform (unweighted) median.
|
||||||
|
*/
|
||||||
|
export function hasGeo(): boolean {
|
||||||
|
return data !== null && Object.keys(data.geo).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precomputed anchor point for a region key: the centroid of its
|
||||||
|
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||||||
|
* dimension, in degrees) the engine scatters markers within
|
||||||
|
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
|
||||||
|
* centroid data is loaded (v0.27, #44).
|
||||||
|
*/
|
||||||
|
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
|
||||||
|
return data?.centroids[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
|
||||||
|
* weighting source for the per-region stats model. `undefined` for an unknown
|
||||||
|
* key or when no geo data is loaded (the derivation then falls back to a
|
||||||
|
* uniform weight of 1 for that region).
|
||||||
|
*/
|
||||||
|
export function geoOf(key: string): { area: number; population: number } | undefined {
|
||||||
|
return data?.geo[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the region-resources manifest loaded with at least one entry
|
||||||
|
* (v0.36, #59). The server's jump route uses this to decide whether to hand
|
||||||
|
* the engine a resource resolver (which lets a turn scale industry boosts by a
|
||||||
|
* region's `industry` value and carry each nation's aggregate resource profile
|
||||||
|
* in the LLM prompt) or skip both (industry boosts fall back to the unscaled
|
||||||
|
* base value).
|
||||||
|
*/
|
||||||
|
export function hasResources(): boolean {
|
||||||
|
return data !== null && Object.keys(data.resources).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The synthesized resource profile for a region key (v0.36, #59): the
|
||||||
|
* four-value `{ oil, iron, food, industry }` from the committed
|
||||||
|
* `public/region-resources.json`. `undefined` for an unknown key or when no
|
||||||
|
* resource data is loaded (industry boosts then use the unscaled base value,
|
||||||
|
* and the aggregate profile is simply omitted from the prompt).
|
||||||
|
*/
|
||||||
|
export function resourceOf(key: string): RegionResources | undefined {
|
||||||
|
return data?.resources[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The union of the region keys that share a land border with any of the given
|
||||||
|
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||||
|
* determinism. This is the data behind the LLM's getNeighbours tool and the
|
||||||
|
* player-frontier computation in the turn prompt (v0.21, #16 P2). Returns
|
||||||
|
* `[]` when no region data (or no adjacency manifest) is loaded.
|
||||||
|
*/
|
||||||
|
export function neighboursOf(keys: Iterable<string>): string[] {
|
||||||
|
if (!data) return [];
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const key of keys) {
|
||||||
|
for (const n of data.adjacency[key] ?? []) out.add(n);
|
||||||
|
}
|
||||||
|
for (const key of keys) out.delete(key); // never report a key as its own neighbour
|
||||||
|
return [...out].sort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
relationFromScore,
|
||||||
|
validateWorldDraft,
|
||||||
|
worldDraftToGameState,
|
||||||
|
type WorldDraft,
|
||||||
|
} from '../shared/scenario';
|
||||||
|
import type { NationStats } from '../shared/types';
|
||||||
|
|
||||||
|
// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per
|
||||||
|
// country), standing in for server/regions.expandCountriesToKeys in these
|
||||||
|
// pure-function tests.
|
||||||
|
const fakeExpand = (countries: string[]): string[] =>
|
||||||
|
countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
||||||
|
|
||||||
|
const S = (m: number, e: number, s: number, p: number): NationStats => ({
|
||||||
|
military: m,
|
||||||
|
economy: e,
|
||||||
|
stability: s,
|
||||||
|
population: p,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relationFromScore (v0.31.0, #52)', () => {
|
||||||
|
it('maps the five sentiment bands to RelationStatus', () => {
|
||||||
|
expect(relationFromScore(100)).toBe('ally');
|
||||||
|
expect(relationFromScore(60)).toBe('ally');
|
||||||
|
expect(relationFromScore(0)).toBe('neutral');
|
||||||
|
expect(relationFromScore(30)).toBe('neutral');
|
||||||
|
expect(relationFromScore(-1)).toBe('rival');
|
||||||
|
expect(relationFromScore(-40)).toBe('rival');
|
||||||
|
expect(relationFromScore(-41)).toBe('hostile');
|
||||||
|
expect(relationFromScore(-80)).toBe('hostile');
|
||||||
|
expect(relationFromScore(-81)).toBe('war');
|
||||||
|
expect(relationFromScore(-100)).toBe('war');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps out-of-range scores before banding', () => {
|
||||||
|
expect(relationFromScore(250)).toBe('ally');
|
||||||
|
expect(relationFromScore(-250)).toBe('war');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateWorldDraft (v0.31.0, #52)', () => {
|
||||||
|
it('returns a clean draft from a valid payload', () => {
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{ id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } },
|
||||||
|
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw);
|
||||||
|
expect(d).not.toBeNull();
|
||||||
|
const draft = d as WorldDraft;
|
||||||
|
expect(draft.nations).toHaveLength(2);
|
||||||
|
expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' });
|
||||||
|
expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90));
|
||||||
|
expect(draft.nations[0].territory).toEqual(['DEU']);
|
||||||
|
expect(draft.nations[0].relations).toEqual({ uk: 80 });
|
||||||
|
expect(draft.events).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => {
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{ name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw) as WorldDraft;
|
||||||
|
expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => {
|
||||||
|
const codes = new Set(['DEU', 'GBR', 'FRA']);
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
name: 'Mixed',
|
||||||
|
// lower-case, duplicated, and one unknown code (USA) to be dropped.
|
||||||
|
territory: ['deu', 'DEU', 'usa', 'gbr'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft;
|
||||||
|
expect(d.nations[0].territory).toEqual(['DEU', 'GBR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unknown codes when no validCountryCodes set is supplied', () => {
|
||||||
|
const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] };
|
||||||
|
const d = validateWorldDraft(raw) as WorldDraft;
|
||||||
|
expect(d.nations[0].territory).toEqual(['AAA', 'BBB']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => {
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{ id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } },
|
||||||
|
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw) as WorldDraft;
|
||||||
|
// de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped.
|
||||||
|
expect(d.nations[0].relations).toEqual({ uk: 55 });
|
||||||
|
// uk: the id-keyed ref to de is kept, clamped (already in range).
|
||||||
|
expect(d.nations[1].relations).toEqual({ de: -70 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps relation scores to -100..100', () => {
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{ id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } },
|
||||||
|
{ id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw) as WorldDraft;
|
||||||
|
expect(d.nations[0].relations).toEqual({ b: 100 });
|
||||||
|
expect(d.nations[1].relations).toEqual({ a: -100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suffices duplicate ids until unique and derives ids from names', () => {
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{ id: 'x', name: 'First', territory: ['AAA'] },
|
||||||
|
{ id: 'x', name: 'Second', territory: ['BBB'] },
|
||||||
|
{ name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw) as WorldDraft;
|
||||||
|
const ids = d.nations.map((n) => n.id);
|
||||||
|
expect(ids).toEqual(['x', 'x-2', 'united-kingdom']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps backstory events that name a known nation and drops the rest', () => {
|
||||||
|
const raw = {
|
||||||
|
nations: [
|
||||||
|
{ id: 'de', name: 'Germany', territory: ['DEU'] },
|
||||||
|
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'] },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' },
|
||||||
|
{ nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' },
|
||||||
|
{ nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' },
|
||||||
|
{ nation: 'de', type: 'political' }, // no title -> dropped
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const d = validateWorldDraft(raw) as WorldDraft;
|
||||||
|
expect(d.events).toHaveLength(2);
|
||||||
|
expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' });
|
||||||
|
expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null (never throws) for unusable / garbage payloads', () => {
|
||||||
|
expect(validateWorldDraft(null)).toBeNull();
|
||||||
|
expect(validateWorldDraft('nope')).toBeNull();
|
||||||
|
expect(validateWorldDraft([])).toBeNull();
|
||||||
|
expect(validateWorldDraft({})).toBeNull();
|
||||||
|
expect(validateWorldDraft({ nations: [] })).toBeNull();
|
||||||
|
expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull();
|
||||||
|
expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name
|
||||||
|
expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object
|
||||||
|
// no nation owns a *valid* territory -> unusable.
|
||||||
|
expect(
|
||||||
|
validateWorldDraft(
|
||||||
|
{ nations: [{ name: 'X', territory: ['ZZZ'] }] },
|
||||||
|
{ validCountryCodes: new Set(['DEU']) },
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worldDraftToGameState (v0.31.0, #52)', () => {
|
||||||
|
function draft(): WorldDraft {
|
||||||
|
return {
|
||||||
|
nations: [
|
||||||
|
{ id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } },
|
||||||
|
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } },
|
||||||
|
],
|
||||||
|
events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('expands ISO_A3 territory to region keys and seeds per-region stats', () => {
|
||||||
|
const g = worldDraftToGameState(draft(), fakeExpand, 'uk', {
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Custom World',
|
||||||
|
startDate: '1939-09-01',
|
||||||
|
});
|
||||||
|
expect(g.id).toBe('g1');
|
||||||
|
expect(g.presetTitle).toBe('Custom World');
|
||||||
|
expect(g.date).toBe('1939-09-01');
|
||||||
|
expect(g.turn).toBe(1);
|
||||||
|
expect(g.version).toBe(1);
|
||||||
|
expect(g.playerNationId).toBe('uk');
|
||||||
|
expect(g.unowned).toEqual([]);
|
||||||
|
expect(g.markers).toEqual([]);
|
||||||
|
expect(g.pendingNationOps).toEqual([]);
|
||||||
|
expect(g.pending).toEqual([]);
|
||||||
|
|
||||||
|
// de owns DEU -> two region keys; uk owns GBR+IRL -> four.
|
||||||
|
expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']);
|
||||||
|
expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']);
|
||||||
|
|
||||||
|
// Every owned region is seeded with a copy of its owner's stats.
|
||||||
|
expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats);
|
||||||
|
expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats);
|
||||||
|
// No entry for unowned regions.
|
||||||
|
expect(g.regionStats['ZZZ-a']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps numeric relations to RelationStatus', () => {
|
||||||
|
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
||||||
|
expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally
|
||||||
|
expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the event feed from backstory events at turn 1 / startDate', () => {
|
||||||
|
const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' });
|
||||||
|
expect(g.events).toHaveLength(1);
|
||||||
|
expect(g.events[0]).toMatchObject({
|
||||||
|
turn: 1,
|
||||||
|
date: '2035-01-01',
|
||||||
|
nation: 'de',
|
||||||
|
type: 'political',
|
||||||
|
title: 'The new order',
|
||||||
|
outcome: 'neutral',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => {
|
||||||
|
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
||||||
|
expect(g.id).toBe('');
|
||||||
|
expect(g.presetTitle).toBe('Custom World');
|
||||||
|
expect(g.date).toBe('1970-01-01');
|
||||||
|
expect(g.presetId).toBe('dynamic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// Unit tests for the dynamic scenario-generation LLM steps (v0.31, #52):
|
||||||
|
// the prompt builders, the defensive parsers, generateDraft / finalizeWorld
|
||||||
|
// over an injected LlmClient, and the deterministic scenario stub.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildGeneratePrompt,
|
||||||
|
buildRegeneratePrompt,
|
||||||
|
finalizeWorld,
|
||||||
|
generateDraft,
|
||||||
|
parseGenerate,
|
||||||
|
parseRegenerate,
|
||||||
|
SCENARIO_GENERATE_MARKER,
|
||||||
|
SCENARIO_REGENERATE_MARKER,
|
||||||
|
} from './scenario';
|
||||||
|
import { makeScenarioStubLlmClient, stubWorldFor } from './stubScenario';
|
||||||
|
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||||
|
import type { ChatMessage, LlmClient } from './llm';
|
||||||
|
|
||||||
|
const TEMPLATE: EraTemplate = {
|
||||||
|
id: 'near-future',
|
||||||
|
name: 'Near Future',
|
||||||
|
year: 2035,
|
||||||
|
eraDescription: 'A fractured, multipolar 2035.',
|
||||||
|
suggestedPowers: [
|
||||||
|
{ name: 'United States', region: 'USA', note: 'declining hegemon' },
|
||||||
|
{ name: 'China', region: 'CHN', note: 'rising industrial power' },
|
||||||
|
],
|
||||||
|
tensions: ['Indo-Pacific standoff', 'Climate migration'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALID_CODES = new Set(['FRA', 'RUS', 'ESP', 'USA', 'CHN']);
|
||||||
|
|
||||||
|
/** A minimal LlmClient double whose chat() returns a canned string (or calls a handler). */
|
||||||
|
function fakeClient(canned?: string | ((messages: ChatMessage[]) => string)): LlmClient {
|
||||||
|
return {
|
||||||
|
async chat(messages) {
|
||||||
|
if (typeof canned === 'function') return canned(messages);
|
||||||
|
return canned ?? '{"world":{"nations":[]},"checkpoints":[]}';
|
||||||
|
},
|
||||||
|
async listModels() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOOD_GENERATE = JSON.stringify({
|
||||||
|
world: {
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 'alba',
|
||||||
|
name: 'Alba',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||||
|
territory: ['FRA'],
|
||||||
|
relations: { boreas: -60 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boreas',
|
||||||
|
name: 'Boreas',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 55, economy: 70, stability: 50, population: 60 },
|
||||||
|
territory: ['RUS'],
|
||||||
|
relations: { alba: -60 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
checkpoints: ['Alba presses its advantage.', 'Boreas fortifies the north.'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const GOOD_REGENERATE = JSON.stringify({
|
||||||
|
world: {
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 'alba',
|
||||||
|
name: 'Alba',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 70, economy: 60, stability: 70, population: 55 },
|
||||||
|
territory: ['FRA'],
|
||||||
|
relations: { boreas: -70 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boreas',
|
||||||
|
name: 'Boreas',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 55, economy: 70, stability: 55, population: 60 },
|
||||||
|
territory: ['RUS'],
|
||||||
|
relations: { alba: -70 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGeneratePrompt', () => {
|
||||||
|
it('embeds the era, description, year and the valid country codes', () => {
|
||||||
|
const { system, user } = buildGeneratePrompt(TEMPLATE, 'a cold war in space', 2041, VALID_CODES);
|
||||||
|
expect(system).toContain(SCENARIO_GENERATE_MARKER);
|
||||||
|
expect(system).toContain('"checkpoints"');
|
||||||
|
expect(user).toContain('near-future');
|
||||||
|
expect(user).toContain('a cold war in space');
|
||||||
|
expect(user).toContain('2041');
|
||||||
|
expect(user).toContain('CHN');
|
||||||
|
expect(user).toContain('validCountryCodes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the code list when no valid codes are supplied', () => {
|
||||||
|
const { system, user } = buildGeneratePrompt(TEMPLATE, 'desc', 2035, undefined);
|
||||||
|
expect(system).toContain('standard ISO 3166-1 alpha-3');
|
||||||
|
expect(user).not.toContain('validCountryCodes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRegeneratePrompt', () => {
|
||||||
|
it('embeds the approved checkpoints and the draft when present', () => {
|
||||||
|
const draft: WorldDraft = {
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 'alba',
|
||||||
|
name: 'Alba',
|
||||||
|
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||||
|
territory: ['FRA'],
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { system, user } = buildRegeneratePrompt(
|
||||||
|
TEMPLATE,
|
||||||
|
'desc',
|
||||||
|
2035,
|
||||||
|
['Alba presses its advantage.'],
|
||||||
|
VALID_CODES,
|
||||||
|
draft,
|
||||||
|
);
|
||||||
|
expect(system).toContain(SCENARIO_REGENERATE_MARKER);
|
||||||
|
expect(user).toContain('approvedCheckpoints');
|
||||||
|
expect(user).toContain('Alba presses its advantage.');
|
||||||
|
expect(user).toContain('draft');
|
||||||
|
expect(user).toContain('alba');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the draft field when none is supplied', () => {
|
||||||
|
const { user } = buildRegeneratePrompt(TEMPLATE, 'desc', 2035, [], VALID_CODES, undefined);
|
||||||
|
expect(user).not.toContain('draft');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseGenerate / parseRegenerate (defensive)', () => {
|
||||||
|
it('parses a valid generate answer into a world + checkpoints', () => {
|
||||||
|
const { world, checkpoints } = parseGenerate(GOOD_GENERATE, VALID_CODES);
|
||||||
|
expect(world).not.toBeNull();
|
||||||
|
expect(world!.nations).toHaveLength(2);
|
||||||
|
expect(world!.nations[0].id).toBe('alba');
|
||||||
|
expect(world!.nations[0].territory).toEqual(['FRA']);
|
||||||
|
expect(checkpoints).toEqual(['Alba presses its advantage.', 'Boreas fortifies the north.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips code fences and drops unknown country codes', () => {
|
||||||
|
const raw = '```json\n' + GOOD_GENERATE + '\n```';
|
||||||
|
const { world } = parseGenerate(raw, new Set(['FRA'])); // RUS not in the set
|
||||||
|
expect(world).not.toBeNull();
|
||||||
|
expect(world!.nations.find((n) => n.id === 'boreas')?.territory).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null world + empty checkpoints for garbage', () => {
|
||||||
|
const { world, checkpoints } = parseGenerate('not json at all', VALID_CODES);
|
||||||
|
expect(world).toBeNull();
|
||||||
|
expect(checkpoints).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid regenerate answer (no checkpoints echoed)', () => {
|
||||||
|
const world = parseRegenerate(GOOD_REGENERATE, VALID_CODES);
|
||||||
|
expect(world).not.toBeNull();
|
||||||
|
expect(world!.nations[0].stats.stability).toBe(70);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateDraft (injected client)', () => {
|
||||||
|
it('returns a validated world + checkpoints from the model', async () => {
|
||||||
|
const { world, checkpoints } = await generateDraft({
|
||||||
|
llm: fakeClient(GOOD_GENERATE),
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'a cold war in space',
|
||||||
|
year: 2041,
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
});
|
||||||
|
expect(world).not.toBeNull();
|
||||||
|
expect(world!.nations).toHaveLength(2);
|
||||||
|
expect(checkpoints).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nudges once when the first answer is unusable', async () => {
|
||||||
|
let call = 0;
|
||||||
|
const client: LlmClient = {
|
||||||
|
async chat() {
|
||||||
|
call += 1;
|
||||||
|
return call === 1 ? 'total garbage' : GOOD_GENERATE;
|
||||||
|
},
|
||||||
|
async listModels() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { world, checkpoints } = await generateDraft({
|
||||||
|
llm: client,
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'x',
|
||||||
|
year: 2035,
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
});
|
||||||
|
expect(call).toBe(2);
|
||||||
|
expect(world).not.toBeNull();
|
||||||
|
expect(checkpoints).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null world when even the nudge fails', async () => {
|
||||||
|
const { world } = await generateDraft({
|
||||||
|
llm: fakeClient('still no json'),
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'x',
|
||||||
|
year: 2035,
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
});
|
||||||
|
expect(world).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finalizeWorld (injected client)', () => {
|
||||||
|
const expand = (countries: string[]) => countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
||||||
|
|
||||||
|
it('converts the finalized world to a playable GameState', async () => {
|
||||||
|
const state = await finalizeWorld({
|
||||||
|
llm: fakeClient(GOOD_REGENERATE),
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'x',
|
||||||
|
year: 2035,
|
||||||
|
approvedCheckpoints: ['Alba presses its advantage.'],
|
||||||
|
playerNationId: 'alba',
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
expand,
|
||||||
|
id: 'g-1',
|
||||||
|
title: 'Custom World',
|
||||||
|
startDate: '2035-01-01',
|
||||||
|
});
|
||||||
|
expect(state.id).toBe('g-1');
|
||||||
|
expect(state.presetId).toBe('dynamic');
|
||||||
|
expect(state.playerNationId).toBe('alba');
|
||||||
|
expect(state.turn).toBe(1);
|
||||||
|
expect(state.date).toBe('2035-01-01');
|
||||||
|
// FRA expands to two region keys; the per-region stats are seeded.
|
||||||
|
expect(state.nations.alba.territory).toEqual(['FRA-a', 'FRA-b']);
|
||||||
|
expect(state.regionStats['FRA-a']).toEqual(state.nations.alba.stats);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first nation when the model drops the player nation', async () => {
|
||||||
|
// The regenerated world has no 'alba' — only 'boreas'.
|
||||||
|
const dropped = JSON.stringify({
|
||||||
|
world: {
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 'boreas',
|
||||||
|
name: 'Boreas',
|
||||||
|
stats: { military: 55, economy: 70, stability: 55, population: 60 },
|
||||||
|
territory: ['RUS'],
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state = await finalizeWorld({
|
||||||
|
llm: fakeClient(dropped),
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'x',
|
||||||
|
year: 2035,
|
||||||
|
approvedCheckpoints: [],
|
||||||
|
playerNationId: 'alba', // not in the world
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
expand,
|
||||||
|
});
|
||||||
|
expect(state.playerNationId).toBe('boreas');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scenario stub (deterministic, LLM_STUB=1)', () => {
|
||||||
|
it('is deterministic: same input → same generate output', async () => {
|
||||||
|
const llm = makeScenarioStubLlmClient();
|
||||||
|
const a = await generateDraft({
|
||||||
|
llm,
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'd',
|
||||||
|
year: 2035,
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
});
|
||||||
|
const b = await generateDraft({
|
||||||
|
llm,
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'completely different description',
|
||||||
|
year: 1999,
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
});
|
||||||
|
expect(a.world).toEqual(b.world);
|
||||||
|
expect(a.checkpoints).toEqual(b.checkpoints);
|
||||||
|
expect(a.world!.nations).toHaveLength(3);
|
||||||
|
expect(a.checkpoints).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalize respects the approved checkpoints (deterministically)', async () => {
|
||||||
|
const baseline = stubWorldFor([]);
|
||||||
|
const withAlba = stubWorldFor(['Alba dominates the western sea lanes.']);
|
||||||
|
const albaBase = baseline.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
|
||||||
|
const boreasBase = baseline.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
|
||||||
|
const albaWith = withAlba.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
|
||||||
|
const boreasWith = withAlba.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
|
||||||
|
// Approving a checkpoint that names Alba raises Alba's stability only.
|
||||||
|
expect(albaWith).toBe(albaBase + 5);
|
||||||
|
expect(boreasWith).toBe(boreasBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizeWorld over the stub returns a playable game honoring approvals', async () => {
|
||||||
|
const llm = makeScenarioStubLlmClient();
|
||||||
|
const state = await finalizeWorld({
|
||||||
|
llm,
|
||||||
|
template: TEMPLATE,
|
||||||
|
description: 'd',
|
||||||
|
year: 2035,
|
||||||
|
approvedCheckpoints: ['Boreas fortifies its northern frontier.'],
|
||||||
|
playerNationId: 'boreas',
|
||||||
|
validCountryCodes: VALID_CODES,
|
||||||
|
expand: (cs) => cs.flatMap((c) => [`${c}-a`]),
|
||||||
|
id: 'stub-1',
|
||||||
|
});
|
||||||
|
expect(state.id).toBe('stub-1');
|
||||||
|
expect(state.playerNationId).toBe('boreas');
|
||||||
|
expect(state.nations.boreas.territory).toEqual(['RUS-a']);
|
||||||
|
// The approved Boreas checkpoint raised its stability by 5 (50 → 55).
|
||||||
|
expect(state.nations.boreas.stats.stability).toBe(55);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
// Dynamic scenario generation — the two LLM steps (v0.31, #52).
|
||||||
|
//
|
||||||
|
// This is the additive "Create scenario" path. Two plain `chat()` calls (no
|
||||||
|
// tools, no separate advisor endpoint — the advisor is embodied in step 1's
|
||||||
|
// checkpoint list):
|
||||||
|
//
|
||||||
|
// 1. `generateDraft` — from an era template + a free-text description + a
|
||||||
|
// year, the model designs a draft {@link WorldDraft} (nations with ISO_A3
|
||||||
|
// territory, stats, relations, optional backstory) AND an opening
|
||||||
|
// checkpoint list (the advisor's briefing of the world's live tensions).
|
||||||
|
//
|
||||||
|
// 2. `finalizeWorld` — on commit, the model is re-asked to produce the
|
||||||
|
// final world, constrained to the player's APPROVED checkpoints (and,
|
||||||
|
// when the client re-sends the step-1 draft, based on it so the player's
|
||||||
|
// chosen nation survives). The result is converted to a playable
|
||||||
|
// {@link GameState} (region-key territory via the injected `expand`
|
||||||
|
// resolver, per-region stats seeded) and returned for the caller to
|
||||||
|
// persist — a normal game at `#/game/<id>`.
|
||||||
|
//
|
||||||
|
// Both steps are defensive like the rest of the engine: raw model output is
|
||||||
|
// fence-stripped, the first balanced object is extracted, JSON.parsed, and
|
||||||
|
// validated via the pure `validateWorldDraft` (which never throws and returns
|
||||||
|
// `null` for unusable input). A transport-level failure (the model being down)
|
||||||
|
// throws and is mapped to an HTTP 502 by the route; a bad *answer* degrades to
|
||||||
|
// a null world, never a crash. The `LlmClient` is injected so unit tests and
|
||||||
|
// the deterministic scenario stub (server/stubScenario.ts) can replace the
|
||||||
|
// network call exactly like the turn path does.
|
||||||
|
import type { LlmClient } from './llm';
|
||||||
|
import { DEFAULT_MAX_TOKENS } from './llm';
|
||||||
|
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||||
|
import { validateWorldDraft } from '../shared/scenario';
|
||||||
|
import type { GameState } from '../shared/types';
|
||||||
|
import { worldDraftToGameState } from '../shared/scenario';
|
||||||
|
|
||||||
|
// --- Defensive parsing (shared with the engine's approach) --------------------
|
||||||
|
|
||||||
|
/** The system-prompt markers the deterministic scenario stub keys off. */
|
||||||
|
export const SCENARIO_GENERATE_MARKER = '<<SCENARIO_GENERATE>>';
|
||||||
|
export const SCENARIO_REGENERATE_MARKER = '<<SCENARIO_REGENERATE>>';
|
||||||
|
|
||||||
|
/** Strip a leading/trailing ``` code fence (mirrors engine.stripFence). */
|
||||||
|
function stripFence(text: string): string {
|
||||||
|
let t = text.trim();
|
||||||
|
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
|
||||||
|
if (lead) t = t.slice(lead[0].length);
|
||||||
|
const tail = t.match(/\n?\s*```\s*$/);
|
||||||
|
if (tail) t = t.slice(0, t.length - tail[0].length);
|
||||||
|
return t.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the first balanced `{ ... }` substring (string-aware). */
|
||||||
|
function extractBalancedObject(text: string): string | null {
|
||||||
|
const start = text.indexOf('{');
|
||||||
|
if (start === -1) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inStr = false;
|
||||||
|
let esc = false;
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
if (inStr) {
|
||||||
|
if (esc) esc = false;
|
||||||
|
else if (c === '\\') esc = true;
|
||||||
|
else if (c === '"') inStr = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') inStr = true;
|
||||||
|
else if (c === '{') depth++;
|
||||||
|
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a raw top-level JSON object, or `null` when none is recoverable. */
|
||||||
|
function parseObject(raw: string): Record<string, unknown> | null {
|
||||||
|
const jsonText = extractBalancedObject(stripFence(raw));
|
||||||
|
if (!jsonText) return null;
|
||||||
|
try {
|
||||||
|
const obj: unknown = JSON.parse(jsonText);
|
||||||
|
return obj && typeof obj === 'object' && !Array.isArray(obj)
|
||||||
|
? (obj as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Prompt building ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared nation JSON contract text, embedded in both system prompts so the
|
||||||
|
* model sees the exact shape it must emit. Mirrors the engine's strict "ONLY a
|
||||||
|
* valid JSON object" style.
|
||||||
|
*/
|
||||||
|
const WORLD_SHAPE = [
|
||||||
|
'{',
|
||||||
|
' "world": {',
|
||||||
|
' "nations": [',
|
||||||
|
' {',
|
||||||
|
' "id": "short-stable-id",',
|
||||||
|
' "name": "Human readable name",',
|
||||||
|
' "type": "major|minor|power|alliance|faction",',
|
||||||
|
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||||
|
' "territory": ["ISO_A3","ISO_A3"],',
|
||||||
|
' "relations": {"<other nation id or name>": -100..100}',
|
||||||
|
' }',
|
||||||
|
' ],',
|
||||||
|
' "events": [',
|
||||||
|
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||||
|
' ]',
|
||||||
|
' },',
|
||||||
|
' "checkpoints": ["short advisor checkpoint", "..."]',
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system prompt for step 1 (generate). Tells the model to design a fresh
|
||||||
|
* world for the era AND to emit an opening advisor checkpoint list.
|
||||||
|
*/
|
||||||
|
export function buildGeneratePrompt(
|
||||||
|
template: EraTemplate,
|
||||||
|
description: string,
|
||||||
|
year: number,
|
||||||
|
validCountryCodes: ReadonlySet<string> | undefined,
|
||||||
|
): { system: string; user: string } {
|
||||||
|
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||||
|
const system = [
|
||||||
|
`${SCENARIO_GENERATE_MARKER} You are a world-builder for an alternate-history strategy game.`,
|
||||||
|
'Given an era, a starting year, and the designer\'s description, create a plausible, self-consistent world of 3 to 8 nations for that moment in (or diverged from) history.',
|
||||||
|
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
|
||||||
|
WORLD_SHAPE,
|
||||||
|
'',
|
||||||
|
'RULES',
|
||||||
|
'- "nations": 3 to 8 nations, each with a short stable id, a human name, a type, and four 0-100 stats (military, economy, stability, population).',
|
||||||
|
codes
|
||||||
|
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||||
|
codes.join(', ') +
|
||||||
|
'. A nation may control several codes; together the nations should cover a plausible portion of the world.'
|
||||||
|
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||||
|
'- "relations": a nation\'s sentiment toward each other nation, an integer from -100 (warring) to +100 (allied). Omit or set 0 for neutrality.',
|
||||||
|
'- "events" (optional): 1 to 4 short backstory events that already happened and set up the current tensions; each names one of the nations above.',
|
||||||
|
'- "checkpoints": 3 to 6 short strings — the advisor\'s opening briefing of the world\'s live strategic questions (the pressures, rivalries, and opportunities the player should know before leading a nation). Keep each under 20 words.',
|
||||||
|
'- Keep every stat value between 0 and 100. Do not invent nations outside the "nations" array.',
|
||||||
|
].join('\n');
|
||||||
|
const user = JSON.stringify(
|
||||||
|
{
|
||||||
|
era: {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
year,
|
||||||
|
description: template.eraDescription,
|
||||||
|
},
|
||||||
|
suggestedPowers: template.suggestedPowers,
|
||||||
|
tensions: template.tensions,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
...(codes ? { validCountryCodes: codes } : {}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
return { system, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system prompt for step 2 (regenerate/finalize). Same world contract, but
|
||||||
|
* the model must now respect the player's APPROVED checkpoints (and, when a
|
||||||
|
* draft is supplied, stay close to it so the player's chosen nation survives).
|
||||||
|
*/
|
||||||
|
export function buildRegeneratePrompt(
|
||||||
|
template: EraTemplate,
|
||||||
|
description: string,
|
||||||
|
year: number,
|
||||||
|
approvedCheckpoints: string[],
|
||||||
|
validCountryCodes: ReadonlySet<string> | undefined,
|
||||||
|
draft?: WorldDraft,
|
||||||
|
): { system: string; user: string } {
|
||||||
|
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||||
|
const system = [
|
||||||
|
`${SCENARIO_REGENERATE_MARKER} You are finalizing a world for an alternate-history strategy game.`,
|
||||||
|
'A draft world was generated and the player has APPROVED a set of checkpoints that the final world MUST respect. Produce the final, playable world.',
|
||||||
|
'Respond with ONLY a valid JSON object of exactly this shape (same as the draft, but the "checkpoints" field is NOT repeated — omit it). No prose, no commentary, no markdown fences, no trailing commas:',
|
||||||
|
[
|
||||||
|
'{',
|
||||||
|
' "world": {',
|
||||||
|
' "nations": [',
|
||||||
|
' {',
|
||||||
|
' "id": "short-stable-id",',
|
||||||
|
' "name": "Human readable name",',
|
||||||
|
' "type": "major|minor|power|alliance|faction",',
|
||||||
|
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||||
|
' "territory": ["ISO_A3","ISO_A3"],',
|
||||||
|
' "relations": {"<other nation id or name>": -100..100}',
|
||||||
|
' }',
|
||||||
|
' ],',
|
||||||
|
' "events": [',
|
||||||
|
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||||
|
' ]',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
'',
|
||||||
|
'RULES',
|
||||||
|
'- Base the final world on the provided draft (the "draft" field of the user message) and keep the same nations — do not rename, drop, or invent nations beyond what the draft established, UNLESS an approved checkpoint demands it.',
|
||||||
|
'- You MUST respect EVERY approved checkpoint (the "approvedCheckpoints" field of the user message): reflect each one in the nations\' stats, borders, relations, or backstory events.',
|
||||||
|
codes
|
||||||
|
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||||
|
codes.join(', ') +
|
||||||
|
'.'
|
||||||
|
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||||
|
'- Keep every stat value between 0 and 100.',
|
||||||
|
].join('\n');
|
||||||
|
const user = JSON.stringify(
|
||||||
|
{
|
||||||
|
era: {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
year,
|
||||||
|
description: template.eraDescription,
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
approvedCheckpoints,
|
||||||
|
...(draft ? { draft } : {}),
|
||||||
|
...(codes ? { validCountryCodes: codes } : {}),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
return { system, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parsing the model's answers ----------------------------------------------
|
||||||
|
|
||||||
|
/** Parse the step-1 (generate) answer into a validated world + checkpoint list. */
|
||||||
|
export function parseGenerate(
|
||||||
|
raw: string,
|
||||||
|
validCountryCodes?: ReadonlySet<string>,
|
||||||
|
): { world: WorldDraft | null; checkpoints: string[] } {
|
||||||
|
const obj = parseObject(raw);
|
||||||
|
if (!obj) return { world: null, checkpoints: [] };
|
||||||
|
const world = validateWorldDraft(obj.world, { validCountryCodes });
|
||||||
|
const checkpoints: string[] = [];
|
||||||
|
if (Array.isArray(obj.checkpoints)) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of obj.checkpoints) {
|
||||||
|
if (typeof c !== 'string') continue;
|
||||||
|
const t = c.trim();
|
||||||
|
if (t.length > 2 && !seen.has(t)) {
|
||||||
|
seen.add(t);
|
||||||
|
checkpoints.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { world, checkpoints: checkpoints.slice(0, 12) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the step-2 (finalize) answer into a validated world (or null). */
|
||||||
|
export function parseRegenerate(
|
||||||
|
raw: string,
|
||||||
|
validCountryCodes?: ReadonlySet<string>,
|
||||||
|
): WorldDraft | null {
|
||||||
|
const obj = parseObject(raw);
|
||||||
|
if (!obj) return null;
|
||||||
|
return validateWorldDraft(obj.world, { validCountryCodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Orchestration -------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GenerateDraftOptions {
|
||||||
|
llm: LlmClient;
|
||||||
|
template: EraTemplate;
|
||||||
|
description: string;
|
||||||
|
year: number;
|
||||||
|
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||||
|
validCountryCodes?: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: ask the model to design a draft world + an advisor checkpoint list.
|
||||||
|
* Throws only on a transport-level failure (the model is down) — a bad answer
|
||||||
|
* yields `{ world: null, checkpoints: [] }` and is the caller's to surface as
|
||||||
|
* an error, never a crash.
|
||||||
|
*/
|
||||||
|
export async function generateDraft(
|
||||||
|
opts: GenerateDraftOptions,
|
||||||
|
): Promise<{ world: WorldDraft | null; checkpoints: string[] }> {
|
||||||
|
const { llm, template, description, year, validCountryCodes } = opts;
|
||||||
|
const { system, user } = buildGeneratePrompt(template, description, year, validCountryCodes);
|
||||||
|
const nudge =
|
||||||
|
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||||
|
const out = await llm.chat(
|
||||||
|
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||||
|
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||||
|
);
|
||||||
|
const parsed = parseGenerate(out, validCountryCodes);
|
||||||
|
if (parsed.world) return parsed;
|
||||||
|
// One nudge re-send with a fresh history, mirroring the engine's retry.
|
||||||
|
const retry = await llm.chat(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
{ role: 'assistant', content: '' },
|
||||||
|
{ role: 'user', content: nudge },
|
||||||
|
],
|
||||||
|
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||||
|
);
|
||||||
|
return parseGenerate(retry, validCountryCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalizeWorldOptions {
|
||||||
|
llm: LlmClient;
|
||||||
|
template: EraTemplate;
|
||||||
|
description: string;
|
||||||
|
year: number;
|
||||||
|
approvedCheckpoints: string[];
|
||||||
|
/** The step-1 draft (re-sent by the client) — the base the final world refines. */
|
||||||
|
draft?: WorldDraft;
|
||||||
|
/** The nation id the player will lead (kept in the final world when present). */
|
||||||
|
playerNationId: string;
|
||||||
|
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||||
|
validCountryCodes?: ReadonlySet<string>;
|
||||||
|
/** Expand ISO_A3 territory to sub-region keys (injected from server/regions). */
|
||||||
|
expand: (countries: string[]) => string[];
|
||||||
|
/** The new game's id (the route sets randomUUID()). */
|
||||||
|
id?: string;
|
||||||
|
/** The scenario title (becomes presetTitle; default 'Custom World'). */
|
||||||
|
title?: string;
|
||||||
|
/** The game's start date (YYYY-MM-DD, from the chosen year). */
|
||||||
|
startDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: ask the model to produce the final world constrained to the approved
|
||||||
|
* checkpoints, convert it to a playable {@link GameState}, and return it for the
|
||||||
|
* caller to persist. Throws only on a transport-level failure; a bad answer
|
||||||
|
* that still yields a usable world is committed, and if the final world drops
|
||||||
|
* the player's chosen nation the conversion falls back to the first nation so
|
||||||
|
* the game is always playable.
|
||||||
|
*/
|
||||||
|
export async function finalizeWorld(opts: FinalizeWorldOptions): Promise<GameState> {
|
||||||
|
const {
|
||||||
|
llm,
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
approvedCheckpoints,
|
||||||
|
draft,
|
||||||
|
playerNationId,
|
||||||
|
validCountryCodes,
|
||||||
|
expand,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
startDate,
|
||||||
|
} = opts;
|
||||||
|
const { system, user } = buildRegeneratePrompt(
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
approvedCheckpoints,
|
||||||
|
validCountryCodes,
|
||||||
|
draft,
|
||||||
|
);
|
||||||
|
const nudge =
|
||||||
|
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||||
|
const out = await llm.chat(
|
||||||
|
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||||
|
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||||
|
);
|
||||||
|
let world = parseRegenerate(out, validCountryCodes);
|
||||||
|
if (!world) {
|
||||||
|
const retry = await llm.chat(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
{ role: 'assistant', content: '' },
|
||||||
|
{ role: 'user', content: nudge },
|
||||||
|
],
|
||||||
|
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||||
|
);
|
||||||
|
world = parseRegenerate(retry, validCountryCodes);
|
||||||
|
}
|
||||||
|
if (!world) {
|
||||||
|
// The model could not produce a usable final world. Rather than fail the
|
||||||
|
// whole commit, fall back to the draft the player already approved (if any)
|
||||||
|
// so the commit still yields a playable game.
|
||||||
|
if (draft) world = draft;
|
||||||
|
else throw new Error('finalize: the model returned no usable world');
|
||||||
|
}
|
||||||
|
return worldDraftToGameState(world, expand, playerNationId, { id, title, startDate });
|
||||||
|
}
|
||||||
+161
-4
@@ -2,8 +2,8 @@
|
|||||||
// 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 TurnEventSpec, type TurnResult } from '../shared/protocol';
|
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||||
import type { RoundItem } from '../shared/types';
|
import type { RelationStatus, RoundItem } from '../shared/types';
|
||||||
|
|
||||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||||
|
|
||||||
@@ -13,10 +13,88 @@ export interface StubPayload {
|
|||||||
turn: number;
|
turn: number;
|
||||||
date: string;
|
date: string;
|
||||||
playerNationId: string;
|
playerNationId: string;
|
||||||
nations: Record<string, { stats: Record<string, number> }>;
|
// `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by
|
||||||
|
// the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
|
||||||
|
// it — it never arms or strikes (see deterministicTurn).
|
||||||
|
// `leader` (v0.38.0, #61) is the per-nation named leader stamped by the
|
||||||
|
// engine; the stub ignores it — it never changes a leader (see
|
||||||
|
// deterministicTurn).
|
||||||
|
nations: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
stats: Record<string, number>;
|
||||||
|
territory?: string[];
|
||||||
|
nuclear?: boolean;
|
||||||
|
leader?: { name: string };
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||||
pending?: RoundItem[];
|
pending?: RoundItem[];
|
||||||
|
/**
|
||||||
|
* Region keys owned by other nations that share a land border with the
|
||||||
|
* player's territory — the engine's `frontier` field, computed from the
|
||||||
|
* adjacency manifest (v0.21, #16 P2). The stub cannot call tools, so it
|
||||||
|
* reads the frontier from the prompt instead.
|
||||||
|
*/
|
||||||
|
frontier?: string[];
|
||||||
|
/**
|
||||||
|
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
|
||||||
|
* emitted by the engine's user prompt. The stub ignores this field — it
|
||||||
|
* never releases a region (see deterministicTurn), so no-man's-land behavior
|
||||||
|
* is covered by unit tests against applyTurnResult, not by the stub.
|
||||||
|
*/
|
||||||
|
unowned?: string[];
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`),
|
||||||
|
* emitted by the engine's user prompt. The stub ignores this field — it
|
||||||
|
* never places or removes a marker (see deterministicTurn), so the marker
|
||||||
|
* path is covered by unit tests against applyTurnResult, not by the stub.
|
||||||
|
*/
|
||||||
|
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by
|
||||||
|
* the engine's user prompt. The stub ignores this field — it never declares
|
||||||
|
* or ends a war (see deterministicTurn), so the war path is covered by unit
|
||||||
|
* tests against applyTurnResult, not by the stub.
|
||||||
|
*/
|
||||||
|
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
|
||||||
|
/**
|
||||||
|
* (v0.34.0, #57) The player's current relations to every other nation
|
||||||
|
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
|
||||||
|
* stub ignores this field — it never runs a hybrid op (see
|
||||||
|
* deterministicTurn), so the hybrid path is covered by unit tests only.
|
||||||
|
*/
|
||||||
|
relations?: Record<string, RelationStatus>;
|
||||||
|
/**
|
||||||
|
* (v0.35.0, #58) The currently active bilateral trade agreements
|
||||||
|
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
|
||||||
|
* stub ignores this field — it never declares, lifts or suspends a trade
|
||||||
|
* (see deterministicTurn), so the trade path is covered by unit tests only.
|
||||||
|
*/
|
||||||
|
trades?: Array<{ a: string; b: string }>;
|
||||||
|
/**
|
||||||
|
* (v0.35.0, #58) The currently active unilateral embargoes
|
||||||
|
* (`{ from, target }` nation-id pairs), emitted by the engine's user
|
||||||
|
* prompt. The stub ignores this field — it never imposes, lifts or
|
||||||
|
* suspends an embargo (see deterministicTurn), so the embargo path is
|
||||||
|
* covered by unit tests only.
|
||||||
|
*/
|
||||||
|
embargoes?: Array<{ from: string; target: string }>;
|
||||||
|
/**
|
||||||
|
* (v0.36.0, #59) Each nation's aggregate resource profile
|
||||||
|
* (`{ oil, iron, food, industry }`), emitted by the engine's user prompt.
|
||||||
|
* The stub ignores this field — it never declares an industry project (see
|
||||||
|
* deterministicTurn), so the industry path is covered by unit tests only.
|
||||||
|
*/
|
||||||
|
resources?: Record<string, { oil: number; iron: number; food: number; industry: number }>;
|
||||||
|
/**
|
||||||
|
* (v0.36.0, #59) The currently active industry projects
|
||||||
|
* (`{ regionKey, type }`), emitted by the engine's user prompt. The stub
|
||||||
|
* ignores this field — it never declares an industry project (see
|
||||||
|
* deterministicTurn), so the industry path is covered by unit tests only.
|
||||||
|
*/
|
||||||
|
industry?: Array<{ regionKey: string; type: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -31,6 +109,57 @@ function truncate(s: string, n: number): string {
|
|||||||
* caller can predict the exact next state for assertions. It always sets the
|
* 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
|
* player's military to (current + 5) and allies the player with the first other
|
||||||
* nation, advancing the date by 2 days.
|
* nation, advancing the date by 2 days.
|
||||||
|
*
|
||||||
|
* (v0.21, #16 P2) It also makes ONE contiguous conquest per turn: the player
|
||||||
|
* gains the first frontier region (sorted order) from its current owner, and
|
||||||
|
* that owner loses it. The stub bypasses the tool loop (it answers with plain
|
||||||
|
* content in one round), so the frontier comes from the prompt payload, not
|
||||||
|
* from a getNeighbours call. With an empty frontier (no controllable border)
|
||||||
|
* no territory change is emitted, keeping every existing assertion stable.
|
||||||
|
*
|
||||||
|
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
|
||||||
|
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
||||||
|
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
||||||
|
* tests only (see server/engine.test.ts).
|
||||||
|
*
|
||||||
|
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
||||||
|
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
||||||
|
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
||||||
|
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
||||||
|
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.29, #46 PR 2) The stub likewise NEVER changes per-region stats (it
|
||||||
|
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||||
|
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||||
|
* untouched.
|
||||||
|
*
|
||||||
|
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
|
||||||
|
* (it always returns `takeovers: []`) — the regime-rename path is covered by
|
||||||
|
* unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
|
||||||
|
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
|
||||||
|
* tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
|
||||||
|
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
|
||||||
|
* tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
|
||||||
|
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
|
||||||
|
* the trade & embargo economy path is covered by unit tests only, keeping
|
||||||
|
* the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
|
||||||
|
* always returns `industry: { projects: [] }`) — the per-region industry
|
||||||
|
* path is covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.37.0, #60) The stub likewise NEVER arms a nation or fires a nuclear
|
||||||
|
* strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
|
||||||
|
* nuclear / WMD path is covered by unit tests only, keeping the e2e
|
||||||
|
* assertions untouched.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -42,6 +171,23 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
: 'us';
|
: 'us';
|
||||||
const items = p.pending ?? [];
|
const items = p.pending ?? [];
|
||||||
|
|
||||||
|
// The one deterministic region transfer: the player takes the first
|
||||||
|
// frontier region (sorted → stable) from whichever other nation owns it.
|
||||||
|
let territoryChanges: TerritoryChange[] = [];
|
||||||
|
const frontier = p.frontier ?? [];
|
||||||
|
if (state && frontier.length > 0) {
|
||||||
|
const key = frontier[0];
|
||||||
|
const loserId = Object.keys(state.nations).find(
|
||||||
|
(id) => id !== pid && (state.nations[id].territory ?? []).includes(key),
|
||||||
|
);
|
||||||
|
if (loserId) {
|
||||||
|
territoryChanges = [
|
||||||
|
{ nation: pid, gain: [key], lose: [] },
|
||||||
|
{ nation: loserId, gain: [], lose: [key] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const events: TurnEventSpec[] =
|
const events: TurnEventSpec[] =
|
||||||
items.length === 0
|
items.length === 0
|
||||||
? [
|
? [
|
||||||
@@ -75,8 +221,19 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
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 }],
|
||||||
|
regionChanges: [], // the stub never changes per-region stats (see doc comment)
|
||||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||||
territoryChanges: [],
|
territoryChanges,
|
||||||
|
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||||
|
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||||
|
takeovers: [], // the stub never performs a takeover (see doc comment)
|
||||||
|
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
|
||||||
|
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
|
||||||
|
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
|
||||||
|
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
|
||||||
|
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
|
||||||
|
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
|
||||||
|
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Deterministic SCENARIO-generation stub (v0.31, #52).
|
||||||
|
//
|
||||||
|
// Distinct from the turn stub (server/stub.ts, which stays untouched): this one
|
||||||
|
// answers the two scenario-generation LLM calls (see server/scenario.ts) so the
|
||||||
|
// whole "Create scenario" path is reproducible under LLM_STUB=1 (unit tests and
|
||||||
|
// the Playwright e2e). It speaks the OpenAI chat-completions wire shape, so it
|
||||||
|
// plugs in as a Transport, and it discriminates the two calls by the markers
|
||||||
|
// scenario.ts embeds in each system message (SCENARIO_GENERATE_MARKER for the
|
||||||
|
// draft step, SCENARIO_REGENERATE_MARKER for the finalize step) — mirroring how
|
||||||
|
// the turn stub discriminates the advisor call via ADVISOR_MARKER.
|
||||||
|
//
|
||||||
|
// The stub returns a fixed 3-nation world and a fixed checkpoint list for the
|
||||||
|
// generate step, and — for the finalize step — the same world refined in a
|
||||||
|
// simple, deterministic way that depends on the approved checkpoints, so the
|
||||||
|
// "finalize respects approved checkpoints" behavior is observable end to end.
|
||||||
|
import { createLlmClient, type ChatMessage, type LlmClient, type Transport } from './llm';
|
||||||
|
import { SCENARIO_REGENERATE_MARKER } from './scenario';
|
||||||
|
import type { WorldDraft } from '../shared/scenario';
|
||||||
|
|
||||||
|
/** A small fixed 3-nation world (ISO_A3 territory — FRA / RUS / ESP). */
|
||||||
|
const WORLD: WorldDraft = {
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 'alba',
|
||||||
|
name: 'Alba',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||||
|
territory: ['FRA'],
|
||||||
|
relations: { boreas: -60, gamma: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boreas',
|
||||||
|
name: 'Boreas',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 55, economy: 70, stability: 50, population: 60 },
|
||||||
|
territory: ['RUS'],
|
||||||
|
relations: { alba: -60, gamma: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gamma',
|
||||||
|
name: 'Gamma',
|
||||||
|
type: 'minor',
|
||||||
|
stats: { military: 30, economy: 45, stability: 70, population: 40 },
|
||||||
|
territory: ['ESP'],
|
||||||
|
relations: { alba: 20, boreas: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
nation: 'alba',
|
||||||
|
type: 'political',
|
||||||
|
title: 'The new order takes shape',
|
||||||
|
text: 'The three powers settle their borders after the great realignment.',
|
||||||
|
outcome: 'neutral',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The advisor's opening checkpoint list (fixed, in order). */
|
||||||
|
const CHECKPOINTS: string[] = [
|
||||||
|
'Alba dominates the western sea lanes and presses its advantage.',
|
||||||
|
'Boreas fortifies its northern frontier against a long winter.',
|
||||||
|
'Gamma brokers a neutral corridor between the two rivals.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically refine the fixed world to "respect" the approved
|
||||||
|
* checkpoints: each approved checkpoint that names one of the fixed nations (by
|
||||||
|
* the first 4 letters of the nation's name, case-insensitive) raises that
|
||||||
|
* nation's stability by 5 (clamped at 100). Same input → same output, and the
|
||||||
|
* result visibly depends on which checkpoints were approved.
|
||||||
|
*/
|
||||||
|
export function stubWorldFor(approved: string[]): WorldDraft {
|
||||||
|
const nations = WORLD.nations.map((n) => ({
|
||||||
|
...n,
|
||||||
|
stats: { ...n.stats },
|
||||||
|
relations: { ...n.relations },
|
||||||
|
}));
|
||||||
|
const lower = approved.map((c) => c.toLowerCase());
|
||||||
|
for (const n of nations) {
|
||||||
|
const key = n.name.toLowerCase().slice(0, 4);
|
||||||
|
if (lower.some((c) => c.includes(key))) {
|
||||||
|
n.stats.stability = Math.min(100, n.stats.stability + 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nations, events: WORLD.events };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeScenarioStubTransport(): Transport {
|
||||||
|
return async (_url, init) => {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
const req = JSON.parse(String(init?.body ?? '{}')) as { messages?: ChatMessage[] };
|
||||||
|
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
|
||||||
|
const user = req.messages?.find((m) => m.role === 'user');
|
||||||
|
if (sys.includes(SCENARIO_REGENERATE_MARKER)) {
|
||||||
|
// Finalize step: read the approved checkpoints out of the user payload
|
||||||
|
// and refine the fixed world accordingly (no checkpoints field echoed).
|
||||||
|
const payload = JSON.parse(user?.content ?? '{}') as {
|
||||||
|
approvedCheckpoints?: string[];
|
||||||
|
};
|
||||||
|
content = JSON.stringify({ world: stubWorldFor(payload.approvedCheckpoints ?? []) });
|
||||||
|
} else {
|
||||||
|
// Generate step (and any unrecognized prompt): the fixed world + list.
|
||||||
|
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ choices: [{ message: { content } }] }),
|
||||||
|
text: async () => content,
|
||||||
|
} as unknown as Response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ready-made LlmClient backed by the deterministic scenario transport. */
|
||||||
|
export function makeScenarioStubLlmClient(): LlmClient {
|
||||||
|
return createLlmClient({ transport: makeScenarioStubTransport() });
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createTemplateStore } from './templates';
|
||||||
|
|
||||||
|
// The project root is the parent of server/ (mirrors how server/index.ts and
|
||||||
|
// server/regions.test.ts locate it via import.meta.url).
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const templatesDir = path.join(root, 'presets', 'templates');
|
||||||
|
|
||||||
|
const store = createTemplateStore(templatesDir);
|
||||||
|
|
||||||
|
/** A well-formed ISO_A3 country code: exactly three uppercase letters. */
|
||||||
|
function isIsoA3(code: string): boolean {
|
||||||
|
return /^[A-Z]{3}$/.test(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('era templates store (v0.31.0, #52)', () => {
|
||||||
|
it('the manifest lists exactly the three era templates', () => {
|
||||||
|
const meta = store.listMeta();
|
||||||
|
expect(meta).toHaveLength(3);
|
||||||
|
const ids = meta.map((m) => m.id);
|
||||||
|
expect(ids).toEqual(['ww2', 'present', 'near-future']);
|
||||||
|
for (const m of meta) {
|
||||||
|
expect(m.id).toBeTruthy();
|
||||||
|
expect(m.name).toBeTruthy();
|
||||||
|
expect(typeof m.year).toBe('number');
|
||||||
|
expect(m.eraDescription).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each template file loads and is well-formed', () => {
|
||||||
|
for (const m of store.listMeta()) {
|
||||||
|
const t = store.get(m.id);
|
||||||
|
expect(t, `template ${m.id} should load`).not.toBeNull();
|
||||||
|
const tpl = t as NonNullable<typeof t>;
|
||||||
|
expect(tpl.id).toBe(m.id);
|
||||||
|
expect(tpl.name).toBe(m.name);
|
||||||
|
expect(tpl.year).toBe(m.year);
|
||||||
|
expect(tpl.eraDescription).toBeTruthy();
|
||||||
|
// suggested powers: non-empty, each a named power with an ISO_A3 core region + note.
|
||||||
|
expect(tpl.suggestedPowers.length).toBeGreaterThan(0);
|
||||||
|
for (const p of tpl.suggestedPowers) {
|
||||||
|
expect(p.name).toBeTruthy();
|
||||||
|
expect(isIsoA3(p.region)).toBe(true);
|
||||||
|
expect(p.note).toBeTruthy();
|
||||||
|
}
|
||||||
|
// tensions: a non-empty list of non-empty strings.
|
||||||
|
expect(tpl.tensions.length).toBeGreaterThan(0);
|
||||||
|
for (const s of tpl.tensions) expect(typeof s).toBe('string');
|
||||||
|
for (const s of tpl.tensions) expect(s.trim()).not.toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get returns null for an unknown id', () => {
|
||||||
|
expect(store.get('does-not-exist')).toBeNull();
|
||||||
|
expect(store.get('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Load era templates for the "Create scenario" path from disk (v0.31.0, #52).
|
||||||
|
// Mirrors the preset store (server/presets.ts): each template is a JSON file
|
||||||
|
// (presets/templates/<id>.json) and index.json is the lightweight manifest used
|
||||||
|
// to render the picker cards. The store is created with the resolved templates
|
||||||
|
// directory so it works from repo root (dev) and /app (Docker) alike.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import type { EraTemplate, TemplateMeta } from '../shared/scenario';
|
||||||
|
|
||||||
|
export interface TemplateStore {
|
||||||
|
listMeta(): TemplateMeta[];
|
||||||
|
get(id: string): EraTemplate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTemplateStore(dir: string): TemplateStore {
|
||||||
|
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as TemplateMeta[];
|
||||||
|
return {
|
||||||
|
listMeta() {
|
||||||
|
return manifest;
|
||||||
|
},
|
||||||
|
get(id: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as EraTemplate;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1001
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
|||||||
|
// Dynamic scenario generation — the shared (dependency-free) contract.
|
||||||
|
//
|
||||||
|
// This is the additive "Create scenario" path: instead of picking one of the
|
||||||
|
// static presets, the user picks a light **era template**, writes a free-text
|
||||||
|
// description, and lets the LLM generate a fresh world (a {@link WorldDraft})
|
||||||
|
// plus an advisor **checkpoint** list. The player reviews the checkpoints,
|
||||||
|
// approves/removes some, and picks a nation to lead; a second LLM call then
|
||||||
|
// finalizes the draft into a playable {@link GameState} (a normal game).
|
||||||
|
//
|
||||||
|
// Everything here is pure and has no runtime dependencies beyond the other
|
||||||
|
// shared modules: it may import types from `./types` and helpers from
|
||||||
|
// `./protocol`, but it never imports the engine, the LLM client, or any
|
||||||
|
// external package. The two LLM calls (`generateDraft` / `finalizeWorld`)
|
||||||
|
// live in `server/scenario.ts`; only the *shapes* and the pure transforms
|
||||||
|
// live here.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EventEntry,
|
||||||
|
EventOutcome,
|
||||||
|
EventType,
|
||||||
|
GameState,
|
||||||
|
Nation,
|
||||||
|
NationStats,
|
||||||
|
NationType,
|
||||||
|
RelationStatus,
|
||||||
|
} from './types';
|
||||||
|
import { clampStat, makeEvent, seedRegionStats, type TurnEventSpec } from './protocol';
|
||||||
|
|
||||||
|
// --- Era templates -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggested starting power for an era template: a display `name`, an ISO_A3
|
||||||
|
* core `region` (the country the power is rooted in), and a short `note` the
|
||||||
|
* LLM is shown when it designs the world.
|
||||||
|
*/
|
||||||
|
export interface SuggestedPower {
|
||||||
|
name: string;
|
||||||
|
/** ISO_A3 country code the power's core territory sits in. */
|
||||||
|
region: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A light **era template** the user can start from on the "Create scenario"
|
||||||
|
* path (one JSON file per template in `presets/templates/`, plus an
|
||||||
|
* `index.json` manifest). It is deliberately small: a name, a default `year`,
|
||||||
|
* a short `eraDescription`, a few `suggestedPowers`, and a few `tensions` —
|
||||||
|
* a seed the LLM generator expands into a full {@link WorldDraft}.
|
||||||
|
*/
|
||||||
|
export interface EraTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Default starting year (the user may override it in the form). */
|
||||||
|
year: number;
|
||||||
|
/** A short description of the era, shown on the card and fed to the LLM. */
|
||||||
|
eraDescription: string;
|
||||||
|
/** A few suggested starting powers (name + ISO_A3 core region + a note). */
|
||||||
|
suggestedPowers: SuggestedPower[];
|
||||||
|
/** A few suggested tensions / conflicts to seed the world. */
|
||||||
|
tensions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lightweight manifest entry for an era template (the picker card). */
|
||||||
|
export interface TemplateMeta {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
eraDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- World draft -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single nation in a generated world **draft** (before it is committed to a
|
||||||
|
* playable {@link GameState}). Its `territory` holds **ISO_A3 country codes**
|
||||||
|
* (not region keys) — the engine expands them to sub-region keys at commit
|
||||||
|
* time via the injected `expand` resolver. `relations` are the LLM's
|
||||||
|
* numeric sentiment toward other nations (−100..100); the engine maps them to
|
||||||
|
* a {@link RelationStatus} on commit.
|
||||||
|
*/
|
||||||
|
export interface WorldNation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type?: NationType;
|
||||||
|
stats: NationStats;
|
||||||
|
/** ISO_A3 country codes the nation controls. */
|
||||||
|
territory: string[];
|
||||||
|
/** Bilateral sentiment: other nation id -> −100..100. */
|
||||||
|
relations: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An optional narrative backstory event carried by a world draft (turn 0). */
|
||||||
|
export interface ScenarioEvent {
|
||||||
|
nation: string;
|
||||||
|
type: EventType;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
outcome: EventOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft world the LLM generator returns before the player confirms it.
|
||||||
|
* `nations` is required and non-empty; `events` is optional backstory that
|
||||||
|
* seeds the new game's event feed on commit.
|
||||||
|
*/
|
||||||
|
export interface WorldDraft {
|
||||||
|
nations: WorldNation[];
|
||||||
|
events?: ScenarioEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single **advisor checkpoint** — a short string the generator proposes for
|
||||||
|
* the player to approve (or remove) before the world is finalized. The
|
||||||
|
* approved checkpoints are handed to the finalize call as constraints.
|
||||||
|
*/
|
||||||
|
export type Checkpoint = string;
|
||||||
|
|
||||||
|
// --- Client ↔ server message shapes (the two LLM steps) -----------------------
|
||||||
|
|
||||||
|
/** The body of `POST /api/scenarios/draft` (step 1: design the world). */
|
||||||
|
export interface ScenarioDraftRequest {
|
||||||
|
templateId: string;
|
||||||
|
description: string;
|
||||||
|
/** The chosen start year (the server falls back to the template's year). */
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The response of `POST /api/scenarios/draft` — the draft world + checkpoints. */
|
||||||
|
export interface ScenarioDraftResponse {
|
||||||
|
world: WorldDraft;
|
||||||
|
checkpoints: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The body of `POST /api/scenarios/commit` (step 2: finalize + save the game). */
|
||||||
|
export interface ScenarioCommitRequest {
|
||||||
|
templateId: string;
|
||||||
|
description: string;
|
||||||
|
/** The chosen start year (the server falls back to the template's year). */
|
||||||
|
year?: number;
|
||||||
|
/** The step-1 draft, re-sent so the final world stays close to it. */
|
||||||
|
draft?: WorldDraft;
|
||||||
|
/** The checkpoints the player approved (the finalize step's constraints). */
|
||||||
|
approvedCheckpoints: string[];
|
||||||
|
/** The nation id the player will lead (kept in the final world when present). */
|
||||||
|
playerNationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The response of `POST /api/scenarios/commit` — the saved game's id. */
|
||||||
|
export interface ScenarioCommitResponse {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation + transforms (pure) ------------------------------------------
|
||||||
|
|
||||||
|
/** The 8 known event types (a local copy so this module needs no runtime import). */
|
||||||
|
const EVENT_TYPES: readonly string[] = [
|
||||||
|
'military',
|
||||||
|
'diplomatic',
|
||||||
|
'economic',
|
||||||
|
'political',
|
||||||
|
'social',
|
||||||
|
'disaster',
|
||||||
|
'scientific',
|
||||||
|
'cultural',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The 5 known nation types. */
|
||||||
|
const NATION_TYPES: readonly string[] = ['major', 'minor', 'power', 'alliance', 'faction'];
|
||||||
|
|
||||||
|
/** True when `t` is one of the eight known event types. */
|
||||||
|
function isEventType(t: unknown): t is EventType {
|
||||||
|
return typeof t === 'string' && (EVENT_TYPES as string[]).includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `t` is one of the five known event outcomes. */
|
||||||
|
function isEventOutcome(o: unknown): o is EventOutcome {
|
||||||
|
return o === 'success' || o === 'failure' || o === 'partial' || o === 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `t` is one of the five known nation types. */
|
||||||
|
function isNationType(t: unknown): t is NationType {
|
||||||
|
return typeof t === 'string' && (NATION_TYPES as string[]).includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a number to [lo, hi] and round (non-finite → lo). */
|
||||||
|
function clampRange(v: number, lo: number, hi: number): number {
|
||||||
|
if (!Number.isFinite(v)) return lo;
|
||||||
|
return Math.max(lo, Math.min(hi, Math.round(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a raw (unknown) stats object into a clamped NationStats (missing → 50). */
|
||||||
|
function parseStats(raw: unknown): NationStats {
|
||||||
|
const o =
|
||||||
|
raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||||
|
? (raw as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const get = (k: keyof NationStats): number => {
|
||||||
|
const v = o[k];
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) ? clampStat(v) : 50;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
military: get('military'),
|
||||||
|
economy: get('economy'),
|
||||||
|
stability: get('stability'),
|
||||||
|
population: get('population'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short, stable, lowercase slug for a nation name (used as a fallback id):
|
||||||
|
* "United Kingdom" → "united-kingdom". Diacritics stripped, runs of
|
||||||
|
* non-alphanumerics collapsed to a single dash, length capped at 20.
|
||||||
|
*/
|
||||||
|
function slugFromName(name: string): string {
|
||||||
|
const s = name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
if (!s) return '';
|
||||||
|
return s.length > 20 ? s.slice(0, 20).replace(/-+$/, '') : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a numeric sentiment score (−100..100) to a {@link RelationStatus}.
|
||||||
|
* Five bands: ally (≥60), neutral (0..59), rival (−40..−1), hostile (−80..−41),
|
||||||
|
* war (≤−81). Pure.
|
||||||
|
*/
|
||||||
|
export function relationFromScore(v: number): RelationStatus {
|
||||||
|
const c = clampRange(v, -100, 100);
|
||||||
|
if (c >= 60) return 'ally';
|
||||||
|
if (c >= 0) return 'neutral';
|
||||||
|
if (c >= -40) return 'rival';
|
||||||
|
if (c >= -80) return 'hostile';
|
||||||
|
return 'war';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + normalize a raw LLM world-draft payload into a clean
|
||||||
|
* {@link WorldDraft} (or `null` when it is unusable). Never throws:
|
||||||
|
* - the top level must be an object with a non-empty `nations` array;
|
||||||
|
* - each nation needs a non-empty `name`; its `id` is kept when a valid
|
||||||
|
* non-empty string, else derived from a name slug (else `n<index>`);
|
||||||
|
* duplicate ids are suffixed until unique;
|
||||||
|
* - each nation's `type`/stats are clamped (missing stats → 50);
|
||||||
|
* - each nation's `territory` is upper-cased, deduped, and (when
|
||||||
|
* `validCountryCodes` is given) filtered to only known ISO_A3 codes;
|
||||||
|
* - a nation's `relations` are clamped to −100..100 and re-keyed to the
|
||||||
|
* assigned ids (a key that is a known id, or a case-insensitive name
|
||||||
|
* match, is kept; unknown / self references are dropped);
|
||||||
|
* - optional `events` are kept when they name a known nation (by id or
|
||||||
|
* name) and have a non-empty title;
|
||||||
|
* - the result is `null` when there are no nations, or no nation ends up
|
||||||
|
* owning a valid territory (nothing to render on the map).
|
||||||
|
*/
|
||||||
|
export function validateWorldDraft(
|
||||||
|
raw: unknown,
|
||||||
|
opts: { validCountryCodes?: ReadonlySet<string> } = {},
|
||||||
|
): WorldDraft | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
const rawNations = Array.isArray(obj.nations) ? obj.nations : null;
|
||||||
|
if (!rawNations || rawNations.length === 0) return null;
|
||||||
|
|
||||||
|
const validCodes = opts.validCountryCodes;
|
||||||
|
const usedIds = new Set<string>();
|
||||||
|
const nameToId = new Map<string, string>();
|
||||||
|
|
||||||
|
interface Tmp {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: NationType;
|
||||||
|
stats: NationStats;
|
||||||
|
territory: string[];
|
||||||
|
rawRelations: Record<string, number>;
|
||||||
|
}
|
||||||
|
const tmp: Tmp[] = [];
|
||||||
|
|
||||||
|
for (const rn of rawNations) {
|
||||||
|
if (!rn || typeof rn !== 'object') continue;
|
||||||
|
const o = rn as Record<string, unknown>;
|
||||||
|
const name = typeof o.name === 'string' ? o.name.trim() : '';
|
||||||
|
if (name === '') continue; // a nation needs a name
|
||||||
|
|
||||||
|
// id: keep a valid non-empty string, else derive from the name slug, else n<index>.
|
||||||
|
let id = typeof o.id === 'string' && o.id.trim() !== '' ? o.id.trim() : '';
|
||||||
|
if (id === '') id = slugFromName(name) || `n${tmp.length + 1}`;
|
||||||
|
let candidate = id;
|
||||||
|
let k = 2;
|
||||||
|
while (usedIds.has(candidate)) {
|
||||||
|
candidate = `${id}-${k}`;
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
id = candidate;
|
||||||
|
usedIds.add(id);
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
if (!nameToId.has(lowerName)) nameToId.set(lowerName, id);
|
||||||
|
|
||||||
|
const type = isNationType(o.type) ? o.type : 'major';
|
||||||
|
const stats = parseStats(o.stats);
|
||||||
|
|
||||||
|
// territory: upper-case, de-duped, and (optionally) filtered to known codes.
|
||||||
|
const territory: string[] = [];
|
||||||
|
if (Array.isArray(o.territory)) {
|
||||||
|
for (const t of o.territory) {
|
||||||
|
if (typeof t !== 'string') continue;
|
||||||
|
const code = t.trim().toUpperCase();
|
||||||
|
if (code === '') continue;
|
||||||
|
if (validCodes && !validCodes.has(code)) continue; // drop unknown country codes
|
||||||
|
if (!territory.includes(code)) territory.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relations: keep only finite numeric values (clamped later on re-key).
|
||||||
|
const rawRelations: Record<string, number> = {};
|
||||||
|
if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) {
|
||||||
|
for (const [key, v] of Object.entries(o.relations as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) rawRelations[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp.push({ id, name, type, stats, territory, rawRelations });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmp.length === 0) return null;
|
||||||
|
// Unusable when no nation owns a valid territory (there is nothing to render).
|
||||||
|
if (!tmp.some((t) => t.territory.length > 0)) return null;
|
||||||
|
|
||||||
|
const idSet = new Set(tmp.map((t) => t.id));
|
||||||
|
const nations: WorldNation[] = tmp.map((t) => {
|
||||||
|
const relations: Record<string, number> = {};
|
||||||
|
for (const [key, val] of Object.entries(t.rawRelations)) {
|
||||||
|
// Resolve the key: a known id, else a case-insensitive name match.
|
||||||
|
let target = key;
|
||||||
|
if (!idSet.has(target)) {
|
||||||
|
const mapped = nameToId.get(key.toLowerCase());
|
||||||
|
if (mapped) target = mapped;
|
||||||
|
}
|
||||||
|
if (target === t.id) continue; // drop self-references
|
||||||
|
if (!idSet.has(target)) continue; // drop references to unknown nations
|
||||||
|
relations[target] = clampRange(val, -100, 100);
|
||||||
|
}
|
||||||
|
return { id: t.id, name: t.name, type: t.type, stats: t.stats, territory: t.territory, relations };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional backstory events (kept when they name a known nation).
|
||||||
|
let events: ScenarioEvent[] | undefined;
|
||||||
|
if (Array.isArray(obj.events)) {
|
||||||
|
const evs: ScenarioEvent[] = [];
|
||||||
|
for (const re of obj.events) {
|
||||||
|
if (!re || typeof re !== 'object') continue;
|
||||||
|
const e = re as Record<string, unknown>;
|
||||||
|
const title = typeof e.title === 'string' ? e.title.trim() : '';
|
||||||
|
if (title === '') continue;
|
||||||
|
const text = typeof e.text === 'string' ? e.text : '';
|
||||||
|
const type = isEventType(e.type) ? e.type : 'political';
|
||||||
|
const outcome = isEventOutcome(e.outcome) ? e.outcome : 'neutral';
|
||||||
|
let nation = typeof e.nation === 'string' ? e.nation : '';
|
||||||
|
if (nation !== '' && !idSet.has(nation)) {
|
||||||
|
nation = nameToId.get(nation.toLowerCase()) ?? '';
|
||||||
|
}
|
||||||
|
if (nation === '' || !idSet.has(nation)) continue; // drop events with no known owner
|
||||||
|
evs.push({ nation, type, title, text, outcome });
|
||||||
|
}
|
||||||
|
events = evs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nations, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for {@link worldDraftToGameState} (all optional, sensible defaults). */
|
||||||
|
export interface WorldDraftToGameOptions {
|
||||||
|
/** The new game's id (the server sets `randomUUID()`; default `''`). */
|
||||||
|
id?: string;
|
||||||
|
/** The scenario title (becomes `GameState.presetTitle`; default 'Custom World'). */
|
||||||
|
title?: string;
|
||||||
|
/** The game's start date (`YYYY-MM-DD`, from the chosen year; default '1970-01-01'). */
|
||||||
|
startDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a validated {@link WorldDraft} into a fresh, playable {@link
|
||||||
|
* GameState} (a normal game at `#/game/<id>`). Mirrors `POST /api/games`
|
||||||
|
* exactly, except the draft's nations carry **ISO_A3** territory which is
|
||||||
|
* expanded to sub-region keys by the injected `expand` resolver, and
|
||||||
|
* `regionStats` is seeded from the expanded territories via
|
||||||
|
* {@link seedRegionStats} (so every owned region starts with its owner's
|
||||||
|
* stats and the derived aggregate equals them on turn 1).
|
||||||
|
*
|
||||||
|
* The result is a clean turn-1 state: `version: 1`, `turn: 1`, `unowned: []`,
|
||||||
|
* `markers: []`, `pendingNationOps: []`, `pending: []`, the given
|
||||||
|
* `playerNationId`, and a `startDate` from the chosen year. Backstory events
|
||||||
|
* (if any) seed the event feed at turn 1. Pure — no side effects, no I/O.
|
||||||
|
*/
|
||||||
|
export function worldDraftToGameState(
|
||||||
|
draft: WorldDraft,
|
||||||
|
expand: (countries: string[]) => string[],
|
||||||
|
playerNationId: string,
|
||||||
|
opts: WorldDraftToGameOptions = {},
|
||||||
|
): GameState {
|
||||||
|
const id = opts.id ?? '';
|
||||||
|
const title = opts.title ?? 'Custom World';
|
||||||
|
const startDate = opts.startDate ?? '1970-01-01';
|
||||||
|
|
||||||
|
const nations: Record<string, Nation> = {};
|
||||||
|
for (const wn of draft.nations) {
|
||||||
|
const relations: Record<string, RelationStatus> = {};
|
||||||
|
for (const [otherId, score] of Object.entries(wn.relations)) {
|
||||||
|
if (otherId === wn.id) continue; // no self-relations
|
||||||
|
relations[otherId] = relationFromScore(score);
|
||||||
|
}
|
||||||
|
nations[wn.id] = {
|
||||||
|
id: wn.id,
|
||||||
|
name: wn.name,
|
||||||
|
type: wn.type ?? 'major',
|
||||||
|
stats: wn.stats,
|
||||||
|
territory: expand(wn.territory),
|
||||||
|
relations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: a finalize step (or a bad model answer) might drop the nation the
|
||||||
|
// player chose to lead. Rather than produce a game with a dangling
|
||||||
|
// playerNationId, fall back to the first nation (deterministic insertion
|
||||||
|
// order) so the resulting game is always playable.
|
||||||
|
const effectivePlayer =
|
||||||
|
nations[playerNationId] !== undefined
|
||||||
|
? playerNationId
|
||||||
|
: draft.nations.length > 0
|
||||||
|
? draft.nations[0].id
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Seed the event feed from the draft's optional backstory (turn 1, startDate).
|
||||||
|
const events: EventEntry[] = (draft.events ?? []).map((e) =>
|
||||||
|
makeEvent(e as unknown as TurnEventSpec, 1, startDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
const state: GameState = {
|
||||||
|
id,
|
||||||
|
presetId: 'dynamic',
|
||||||
|
presetTitle: title,
|
||||||
|
playerNationId: effectivePlayer,
|
||||||
|
turn: 1,
|
||||||
|
date: startDate,
|
||||||
|
nations,
|
||||||
|
events,
|
||||||
|
pending: [],
|
||||||
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
|
// Seed AFTER the territories hold real region keys: each owned region gets
|
||||||
|
// a copy of its owner's stats, so the derived aggregate equals them.
|
||||||
|
regionStats: seedRegionStats(nations),
|
||||||
|
// (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war`
|
||||||
|
// relation stays a bare/implicit one, not an entity (see syncWarRelations).
|
||||||
|
wars: [],
|
||||||
|
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
|
||||||
|
trades: [],
|
||||||
|
embargoes: [],
|
||||||
|
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
|
||||||
|
industry: [],
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+316
-3
@@ -1,5 +1,9 @@
|
|||||||
// Domain types — the single source of truth shared by the frontend (src/), the
|
// Domain types — the single source of truth shared by the frontend (src/), the
|
||||||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||||||
|
// (The single `import type` below erases at compile time, so no runtime
|
||||||
|
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
||||||
|
// doc comment.)
|
||||||
|
import type { NationOp } from './protocol';
|
||||||
|
|
||||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||||
export interface NationStats {
|
export interface NationStats {
|
||||||
@@ -9,6 +13,36 @@ export interface NationStats {
|
|||||||
population: number;
|
population: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.28, #46) The 0–100 status indices of a **single region**. Per-region stats
|
||||||
|
* are the unit of record: a nation's aggregate {@link NationStats} is derived as
|
||||||
|
* the area×population-weighted median of the stats of the regions it owns (see
|
||||||
|
* `protocol.deriveNationStats`). Every value is clamped to 0–100 by the engine.
|
||||||
|
*/
|
||||||
|
export interface RegionStats {
|
||||||
|
military: number;
|
||||||
|
economy: number;
|
||||||
|
stability: number;
|
||||||
|
population: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) A region's synthesized four-value resource profile — each value
|
||||||
|
* a finite fraction in [0, 1]. Built once per region key by
|
||||||
|
* `scripts/build-region-resources.mjs` into the committed
|
||||||
|
* `public/region-resources.json` (the engine reads it via `regions.resourceOf`).
|
||||||
|
* The engine scales a region's per-turn industry boost by its `industry` value,
|
||||||
|
* and a nation's aggregate profile (the area×population-weighted mean of its
|
||||||
|
* owned regions' profiles, see `protocol.deriveNationResources`) is what the
|
||||||
|
* LLM prompt carries so the model can reason about where to build.
|
||||||
|
*/
|
||||||
|
export interface RegionResources {
|
||||||
|
oil: number;
|
||||||
|
iron: number;
|
||||||
|
food: number;
|
||||||
|
industry: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||||
export type StatKey = (typeof STAT_KEYS)[number];
|
export type StatKey = (typeof STAT_KEYS)[number];
|
||||||
|
|
||||||
@@ -26,8 +60,10 @@ 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 **region keys** (see
|
||||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
|
||||||
|
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
|
||||||
|
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
|
||||||
*/
|
*/
|
||||||
export interface Nation {
|
export interface Nation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +72,22 @@ export interface Nation {
|
|||||||
stats: NationStats;
|
stats: NationStats;
|
||||||
territory: string[];
|
territory: string[];
|
||||||
relations: Record<string, RelationStatus>;
|
relations: Record<string, RelationStatus>;
|
||||||
|
/**
|
||||||
|
* Whether the nation controls nuclear weapons (v0.37, #60). Optional — a
|
||||||
|
* legacy game loaded from SQLite has no flag, which means "not nuclear".
|
||||||
|
* A nation is *nuclear-capable* (for the `nuclear` lever and MAD
|
||||||
|
* retaliation) when this is `true` OR it owns a `rocket` marker (see
|
||||||
|
* `isNuclearCapable` in `protocol.ts`).
|
||||||
|
*/
|
||||||
|
nuclear?: boolean;
|
||||||
|
/**
|
||||||
|
* The nation's current named leader (v0.38, #61). Optional — a legacy game
|
||||||
|
* (or a nation the LLM has never given a leader to) has none, which is the
|
||||||
|
* "no leader" baseline. The LLM changes it via `TurnResult.leaders`
|
||||||
|
* (assassination / death / appointment, see `LeaderChange` in `protocol.ts`);
|
||||||
|
* each change carries a deterministic stability effect + a narration event.
|
||||||
|
*/
|
||||||
|
leader?: { name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Categories a narrative event can take. */
|
/** Categories a narrative event can take. */
|
||||||
@@ -60,7 +112,168 @@ export const EVENT_TYPES: EventType[] = [
|
|||||||
'cultural',
|
'cultural',
|
||||||
];
|
];
|
||||||
|
|
||||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
/**
|
||||||
|
* The outcome of a narrative event. `success` / `failure` / `partial` /
|
||||||
|
* `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is
|
||||||
|
* reserved for the engine: it is emitted automatically when the military hard
|
||||||
|
* gate rejects a contested conquest (an invasion whose attacker was more than
|
||||||
|
* `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it,
|
||||||
|
* and the client renders it in a distinct muted-red failure tone.
|
||||||
|
*/
|
||||||
|
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled';
|
||||||
|
|
||||||
|
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||||
|
//
|
||||||
|
// Named point markers the LLM can place on the map (cities, factories, armies,
|
||||||
|
// ports, capitals, fleets, rocket launch sites, political/social gatherings).
|
||||||
|
// A marker is anchored to a **region key** (see `public/region-keys.json`): the
|
||||||
|
// engine positions it at that region's precomputed centroid (with a small
|
||||||
|
// deterministic offset) and its `ownerNationId` follows the region's current
|
||||||
|
// owner (recomputed on every turn; `null` when the region is unowned / no
|
||||||
|
// man's land). See protocol.ts for the LLM-facing `TurnResult.markers`
|
||||||
|
// contract and the `markerId` derivation.
|
||||||
|
|
||||||
|
/** The eight marker categories the model may place (v0.27, #44). */
|
||||||
|
export type MarkerType =
|
||||||
|
| 'city'
|
||||||
|
| 'factory'
|
||||||
|
| 'army'
|
||||||
|
| 'port'
|
||||||
|
| 'capital'
|
||||||
|
| 'fleet'
|
||||||
|
| 'rocket'
|
||||||
|
| 'gathering';
|
||||||
|
|
||||||
|
/** All marker types, in canonical order (used by the LLM contract + map layer). */
|
||||||
|
export const MARKER_TYPES: MarkerType[] = [
|
||||||
|
'city',
|
||||||
|
'factory',
|
||||||
|
'army',
|
||||||
|
'port',
|
||||||
|
'capital',
|
||||||
|
'fleet',
|
||||||
|
'rocket',
|
||||||
|
'gathering',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named point marker on the map (v0.27, #44). `id` is a stable,
|
||||||
|
* deterministic 4-character code derived from `type + name + regionKey`
|
||||||
|
* (see protocol.markerId) — re-adding the same marker is idempotent and
|
||||||
|
* removing by id is unambiguous. `lat`/`lon` are filled in by the engine from
|
||||||
|
* the region's centroid manifest (never by the model); `ownerNationId` is the
|
||||||
|
* marker's region's current owner (`null` when unowned) and is recomputed by
|
||||||
|
* the engine on every committed turn, so a marker always follows its region.
|
||||||
|
*/
|
||||||
|
export interface MapMarker {
|
||||||
|
id: string;
|
||||||
|
type: MarkerType;
|
||||||
|
name: string;
|
||||||
|
regionKey: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
ownerNationId: string | null;
|
||||||
|
placedTurn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.35.0, #58) An active BILATERAL TRADE agreement between two distinct
|
||||||
|
* nations. Declared by the LLM via `TurnResult.economy.trade` (a no-`lift`
|
||||||
|
* op) and ended by a `lift: true` op; while active it yields a small,
|
||||||
|
* deterministic economy gain to BOTH parties every committed turn. The
|
||||||
|
* engine suspends (removes) the trade automatically when the two parties end
|
||||||
|
* up on opposite sides of an active war (v0.33, #56).
|
||||||
|
*/
|
||||||
|
export interface TradeAgreement {
|
||||||
|
/** First party's nation id. */
|
||||||
|
a: string;
|
||||||
|
/** Second party's nation id (distinct from `a`; the pair is symmetric). */
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.35.0, #58) An active UNILATERAL EMBARGO: `from` blocks trade with
|
||||||
|
* `target` (which must differ from `from`). Declared by the LLM via
|
||||||
|
* `TurnResult.economy.embargo` (a no-`lift` op) and lifted by a `lift: true`
|
||||||
|
* op; while active it applies a small, deterministic economy dip to the
|
||||||
|
* TARGET only, every committed turn. The engine suspends (removes) the
|
||||||
|
* embargo automatically when the two nations end up on opposite sides of an
|
||||||
|
* active war (v0.33, #56).
|
||||||
|
*/
|
||||||
|
export interface Embargo {
|
||||||
|
/** The imposing nation's id. */
|
||||||
|
from: string;
|
||||||
|
/** The embargoed nation's id (distinct from `from`). */
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resources & industry (v0.36, #59) ---------------------------------------
|
||||||
|
//
|
||||||
|
// The LLM can build per-region INDUSTRY PROJECTS (an oil facility, an iron
|
||||||
|
// mine, a food operation, or a factory) on regions it controls; each active
|
||||||
|
// project gives that region's sole owner a small, deterministic economy boost
|
||||||
|
// every committed turn, scaled by the region's `industry` resource value from
|
||||||
|
// the committed resource manifest. The active projects live in
|
||||||
|
// `GameState.industry` — the LLM's "industry" lever.
|
||||||
|
|
||||||
|
/** The four industry project types a region can host (v0.36, #59). */
|
||||||
|
export type IndustryType = 'oil' | 'iron' | 'food' | 'industry';
|
||||||
|
|
||||||
|
/** All industry project types, in canonical order (the LLM contract + validator). */
|
||||||
|
export const INDUSTRY_TYPES: IndustryType[] = ['oil', 'iron', 'food', 'industry'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) One active industry project: a building of a given
|
||||||
|
* {@link IndustryType} in a specific region. The engine keys the per-turn
|
||||||
|
* economy boost by the region's SOLE owner (a region with no owner or 2+
|
||||||
|
* owners yields no boost) and scales it by the region's `industry` resource
|
||||||
|
* value. A region hosts at most ONE project — re-declaring on a region that
|
||||||
|
* already has one is a no-op (the first declared type wins).
|
||||||
|
*/
|
||||||
|
export interface IndustryProject {
|
||||||
|
/** The region key the project sits in (see `public/region-keys.json`). */
|
||||||
|
regionKey: string;
|
||||||
|
/** The kind of project built there (see {@link IndustryType}). */
|
||||||
|
type: IndustryType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
|
||||||
|
* between two or more nations. Unlike the boolean `war` relation (which a
|
||||||
|
* preset or a bare `relations` entry can carry without any structure behind
|
||||||
|
* it), a `War` is an explicit world entity the engine tracks: the LLM
|
||||||
|
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
|
||||||
|
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
|
||||||
|
* `GameState.wars` for the rest of the game as history after it ends.
|
||||||
|
*
|
||||||
|
* **Semantics of `sides`:** the flat list of every belligerent in the war
|
||||||
|
* (≥2 distinct nation ids). Any two distinct members of an active war's
|
||||||
|
* sides are at war with each other — the engine derives the symmetric `war`
|
||||||
|
* relation for every such pair on every turn (see the engine's
|
||||||
|
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
|
||||||
|
* ends. A mutual-defense ally of a belligerent is dragged into the same list
|
||||||
|
* on declaration (the engine does this automatically).
|
||||||
|
*/
|
||||||
|
export interface War {
|
||||||
|
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
|
||||||
|
id: string;
|
||||||
|
/** The belligerent nations (≥2 distinct ids; see the note above). */
|
||||||
|
sides: string[];
|
||||||
|
/** The turn number the war was declared on. */
|
||||||
|
declaredTurn: number;
|
||||||
|
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
|
||||||
|
declaredDate?: string;
|
||||||
|
/** Optional short war aims the LLM names at declaration. */
|
||||||
|
aims?: string[];
|
||||||
|
/**
|
||||||
|
* Lifecycle: `active` until a turn ends the war, then the end outcome —
|
||||||
|
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
|
||||||
|
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
|
||||||
|
*/
|
||||||
|
status: 'active' | 'armistice' | 'capitulation' | 'ended';
|
||||||
|
/** Optional region keys the war's front line runs through (reserved). */
|
||||||
|
front?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** A narrative event the simulation emitted on some turn. */
|
/** A narrative event the simulation emitted on some turn. */
|
||||||
export interface EventEntry {
|
export interface EventEntry {
|
||||||
@@ -118,6 +331,106 @@ export interface GameState {
|
|||||||
* in src/App.tsx. Starts at 1 on game creation.
|
* in src/App.tsx. Starts at 1 on game creation.
|
||||||
*/
|
*/
|
||||||
version: GameVersion;
|
version: GameVersion;
|
||||||
|
/**
|
||||||
|
* (v0.22, #16 P3) Region keys that became **unowned** — "no man's land" —
|
||||||
|
* during play: a region that was owned and was released to no owner by a
|
||||||
|
* committed turn (the LLM narrates the cause in that turn's events). This is
|
||||||
|
* deliberately distinct from the many regions a preset never assigns, which
|
||||||
|
* are also unowned but were never *released* mid-game; only the keys in this
|
||||||
|
* list render as the distinct visible no-man's-land grey (see
|
||||||
|
* protocol.regionFillStyle). A region leaves the list when any nation
|
||||||
|
* re-claims it (an ordinary `gain`). Always `[]` on a fresh game — no
|
||||||
|
* man's land can only arise mid-game. Rows saved before v0.22 load with
|
||||||
|
* this defaulted to `[]` (see server/db.ts).
|
||||||
|
*/
|
||||||
|
unowned: string[];
|
||||||
|
/**
|
||||||
|
* (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
|
||||||
|
* proposed but the player has not yet resolved: a *pending proposal* list,
|
||||||
|
* as opposed to `pending` (the current round's queued player actions). A
|
||||||
|
* turn NEVER applies one — `applyTurnResult` appends the turn's
|
||||||
|
* `nationOps` to this list, and each proposal survives across turns until
|
||||||
|
* the player resolves it: confirming applies it (a rename changes
|
||||||
|
* `Nation.name` only; a split partitions the nation's region-key territory
|
||||||
|
* into the new nations' territories; a merge folds the inputs' territory
|
||||||
|
* into the survivor) and removes it; rejecting removes it without
|
||||||
|
* applying. Always `[]` on a fresh game; rows saved before v0.23 load with
|
||||||
|
* this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
|
||||||
|
* protocol.ts; the import there is type-only, so this file still has no
|
||||||
|
* runtime dependencies.)
|
||||||
|
*/
|
||||||
|
pendingNationOps: NationOp[];
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) Named point markers on the map: the LLM places them (via a
|
||||||
|
* turn's `markers.add`) and removes them (`markers.remove`, by id). The
|
||||||
|
* engine positions each at its region's centroid (deterministic small
|
||||||
|
* offset) and recomputes `ownerNationId` from the region's current owner on
|
||||||
|
* EVERY committed turn (a released region → `null`). Always `[]` on a fresh
|
||||||
|
* game; rows saved before v0.27 load with this defaulted to `[]` (see
|
||||||
|
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||||||
|
*/
|
||||||
|
markers: MapMarker[];
|
||||||
|
/**
|
||||||
|
* (v0.28, #46) Per-region stats, keyed by **region key** (see
|
||||||
|
* `public/region-keys.json`): each owned region carries its own four 0–100
|
||||||
|
* stats, and a nation's aggregate `Nation.stats` is the area×population-
|
||||||
|
* weighted median of the regions it owns (see `protocol.deriveNationStats`).
|
||||||
|
* A region that ends a turn **unowned** (released to no man's land) has NO
|
||||||
|
* entry, and a preset-unassigned region (never owned) has none until first
|
||||||
|
* claimed (re-claiming re-inits it to the gainer's stats). Always present on a
|
||||||
|
* fresh game (every owned region seeded with its owner's initial stats); a
|
||||||
|
* legacy SQLite row without the field is migrated at load (see server/db.ts)
|
||||||
|
* — additive, no data wipe.
|
||||||
|
*/
|
||||||
|
regionStats: Record<string, RegionStats>;
|
||||||
|
/**
|
||||||
|
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
|
||||||
|
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
|
||||||
|
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
|
||||||
|
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
|
||||||
|
* wars on every turn (two nations are in a `war` relation iff they are both
|
||||||
|
* belligerents of ≥1 active war), so the boolean relation and the war
|
||||||
|
* entities can never drift apart. A bare preset `war` relation with no
|
||||||
|
* active war behind it stays as-is (an implicit, unnamed war — never
|
||||||
|
* force-created into an entity). Always `[]` on a fresh game; rows saved
|
||||||
|
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
|
||||||
|
* additive, no data wipe.
|
||||||
|
*/
|
||||||
|
wars: War[];
|
||||||
|
/**
|
||||||
|
* (v0.35.0, #58) Active bilateral trade agreements (see {@link TradeAgreement}):
|
||||||
|
* declared/ended by a turn's `economy.trade` ops (a no-`lift` op declares,
|
||||||
|
* `lift: true` ends). Every committed turn the engine applies a small,
|
||||||
|
* deterministic economy gain to BOTH parties of each active agreement, and
|
||||||
|
* suspends (removes) any agreement whose two parties are on opposite sides
|
||||||
|
* of an active war (v0.33, #56). Always `[]` on a fresh game; rows saved
|
||||||
|
* before v0.35 load with this defaulted to `[]` (see server/db.ts) —
|
||||||
|
* additive, no data wipe.
|
||||||
|
*/
|
||||||
|
trades: TradeAgreement[];
|
||||||
|
/**
|
||||||
|
* (v0.35.0, #58) Active unilateral embargoes (see {@link Embargo}):
|
||||||
|
* imposed/lifted by a turn's `economy.embargo` ops (a no-`lift` op imposes,
|
||||||
|
* `lift: true` lifts). Every committed turn the engine applies a small,
|
||||||
|
* deterministic economy dip to each embargo's TARGET only, and suspends
|
||||||
|
* (removes) any embargo whose two nations are on opposite sides of an
|
||||||
|
* active war (v0.33, #56). Always `[]` on a fresh game; rows saved before
|
||||||
|
* v0.35 load with this defaulted to `[]` (see server/db.ts) — additive,
|
||||||
|
* no data wipe.
|
||||||
|
*/
|
||||||
|
embargoes: Embargo[];
|
||||||
|
/**
|
||||||
|
* (v0.36, #59) Active industry projects (see {@link IndustryProject}): built
|
||||||
|
* by a turn's `industry.projects` ops (the LLM names a region key + a
|
||||||
|
* project type). Each region hosts at most one project — re-declaring on a
|
||||||
|
* region that already has one is a no-op. Every committed turn the engine
|
||||||
|
* gives each project's region's SOLE owner a small, deterministic economy
|
||||||
|
* boost scaled by that region's `industry` resource value. A project on a
|
||||||
|
* region that ends a turn with no owner (or 2+ owners) yields no boost but
|
||||||
|
* is NOT removed. Always `[]` on a fresh game; rows saved before v0.36 load
|
||||||
|
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
|
||||||
|
*/
|
||||||
|
industry: IndustryProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+350
-64
@@ -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,9 @@ 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 ScenarioForm from './components/ScenarioForm';
|
||||||
|
import CheckpointReview from './components/CheckpointReview';
|
||||||
|
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';
|
||||||
@@ -28,9 +28,27 @@ import { api } from './lib/api';
|
|||||||
import { useSse } from './lib/sse';
|
import { useSse } from './lib/sse';
|
||||||
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
|
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
|
||||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||||
|
import type { TemplateMeta, WorldDraft } from '@shared/scenario';
|
||||||
|
|
||||||
/** Which top-level screen the app is showing. */
|
/**
|
||||||
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
|
* Which top-level screen the app is showing. The dynamic path (v0.31, #52) adds
|
||||||
|
* two: 'scenario-form' (era + description + year) and 'scenario-review' (the
|
||||||
|
* generated world + checkpoint checklist + nation pick), the latter carrying
|
||||||
|
* the step-1 draft + chosen template so the finalize step can re-send them.
|
||||||
|
*/
|
||||||
|
type Screen =
|
||||||
|
| { name: 'home' }
|
||||||
|
| { name: 'pick'; preset: Preset }
|
||||||
|
| { name: 'game' }
|
||||||
|
| { name: 'scenario-form' }
|
||||||
|
| {
|
||||||
|
name: 'scenario-review';
|
||||||
|
template: TemplateMeta;
|
||||||
|
description: string;
|
||||||
|
year: number;
|
||||||
|
world: WorldDraft;
|
||||||
|
checkpoints: string[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
|
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
|
||||||
@@ -46,13 +64,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 +103,33 @@ function persistCollapsedMap(m: Record<string, boolean>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
|
||||||
|
type DockMode = 'left' | 'right' | 'float';
|
||||||
|
|
||||||
|
function loadDockMap(): Record<string, DockMode> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
|
||||||
|
if (raw == null) return {};
|
||||||
|
const v: unknown = JSON.parse(raw);
|
||||||
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
||||||
|
const out: Record<string, DockMode> = {};
|
||||||
|
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||||
|
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDockMap(m: Record<string, DockMode>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — dock choices just won't persist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Active-game persistence. The id of the game the user is (or was last) playing
|
// Active-game persistence. The id of the game the user is (or was last) playing
|
||||||
// is remembered in localStorage, so a page refresh drops them straight back
|
// is remembered in localStorage, so a page refresh drops them straight back
|
||||||
@@ -141,6 +193,18 @@ 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);
|
||||||
|
// Separate again: a pending nation-op confirm/reject is its own (fast,
|
||||||
|
// non-LLM) request. It gates only the nation-op dialog controls, never the
|
||||||
|
// turn flow — "Jump forward" stays usable while it is in flight.
|
||||||
|
const [opBusy, setOpBusy] = useState(false);
|
||||||
|
// (v0.31, #52) A scenario-generation LLM call (generate or finalize) is
|
||||||
|
// in flight. Distinct from `busy`/`advisorBusy` — it only gates the
|
||||||
|
// scenario-form/review screens (those screens never coexist with a game).
|
||||||
|
const [scenarioBusy, setScenarioBusy] = useState(false);
|
||||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||||
// list of saved games (newest first) shown on the home screen.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -273,6 +337,12 @@ export default function App() {
|
|||||||
useSse(game?.id ?? null, {
|
useSse(game?.id ?? null, {
|
||||||
onState: applyState,
|
onState: applyState,
|
||||||
onTurn: applyState,
|
onTurn: applyState,
|
||||||
|
// The game disappeared while this tab had it open — either deleted from
|
||||||
|
// another tab (the server broadcasts an SSE `error`) or a stale deep link to
|
||||||
|
// a missing game (the SSE connect emits `error`). Return this tab home.
|
||||||
|
onError: (msg) => {
|
||||||
|
if (/deleted|not found/i.test(msg)) latestRef.current?.goHome();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const play = useCallback(async (presetId: string) => {
|
const play = useCallback(async (presetId: string) => {
|
||||||
@@ -313,6 +383,77 @@ export default function App() {
|
|||||||
[screen],
|
[screen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- (v0.31, #52) Dynamic scenario generation: form → generate → review →
|
||||||
|
// finalize → game. Both LLM steps run on the server (the scenario stub under
|
||||||
|
// LLM_STUB=1, the live model otherwise); on failure we toast and stay on the
|
||||||
|
// current screen (the form's / review's local selections are preserved).
|
||||||
|
|
||||||
|
// Home screen's "Create scenario" card → the scenario form.
|
||||||
|
const openScenarioForm = useCallback(() => {
|
||||||
|
setScreen({ name: 'scenario-form' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Step 1 (generate): design the draft world + checkpoint list. On success
|
||||||
|
// the app moves to the review screen (carrying the draft, template, year,
|
||||||
|
// and description so the finalize step can re-send them).
|
||||||
|
const generateScenarioDraft = useCallback(
|
||||||
|
async (template: TemplateMeta, description: string, year: number) => {
|
||||||
|
setScenarioBusy(true);
|
||||||
|
try {
|
||||||
|
const { world, checkpoints } = await api.draftScenario({
|
||||||
|
templateId: template.id,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
});
|
||||||
|
setScreen({ name: 'scenario-review', template, description, year, world, checkpoints });
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setScenarioBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2 (finalize): commit the approved draft into a saved, playable game
|
||||||
|
// and enter it. The step-1 draft is re-sent so the final world stays close
|
||||||
|
// to it (and the chosen nation survives); the new game's id is then fetched
|
||||||
|
// and adopted exactly like a normal created game (active-game persistence,
|
||||||
|
// applyState, screen → game — the URL-sync effect writes the deep link).
|
||||||
|
const finalizeScenario = useCallback(
|
||||||
|
async (
|
||||||
|
template: TemplateMeta,
|
||||||
|
description: string,
|
||||||
|
year: number,
|
||||||
|
world: WorldDraft,
|
||||||
|
approvedCheckpoints: string[],
|
||||||
|
playerNationId: string,
|
||||||
|
) => {
|
||||||
|
setScenarioBusy(true);
|
||||||
|
try {
|
||||||
|
const { id } = await api.commitScenario({
|
||||||
|
templateId: template.id,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
draft: world,
|
||||||
|
approvedCheckpoints,
|
||||||
|
playerNationId,
|
||||||
|
});
|
||||||
|
const { game } = await api.getGame(id);
|
||||||
|
persistActiveGameId(id);
|
||||||
|
setActiveGameId(id);
|
||||||
|
applyState(game);
|
||||||
|
setAdvisors([]);
|
||||||
|
setScreen({ name: 'game' });
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setScenarioBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyState],
|
||||||
|
);
|
||||||
|
|
||||||
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
|
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
|
||||||
// list updates from the response and the SSE `state` event. Both are
|
// 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
|
// version-gated via applyState, so the item can never be clobbered by a
|
||||||
@@ -359,6 +500,44 @@ export default function App() {
|
|||||||
[game, applyState],
|
[game, applyState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Apply a pending nation op the model proposed (confirm; `edits.name`
|
||||||
|
// overrides a rename/merge name, `edits.playerPart` picks the player's
|
||||||
|
// successor part in a split). Fast, non-LLM — gated by `opBusy`, not `busy`.
|
||||||
|
// The version-bumped state comes back in the response and the SSE `state` event.
|
||||||
|
const runConfirmNationOp = useCallback(
|
||||||
|
async (index: number, edits?: { name?: string; playerPart?: number }) => {
|
||||||
|
if (!game) return;
|
||||||
|
setOpBusy(true);
|
||||||
|
try {
|
||||||
|
const { game: g } = await api.confirmNationOp(game.id, index, edits);
|
||||||
|
applyState(g);
|
||||||
|
toast.success('Nation change applied');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[game, applyState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop a pending nation op (reject / dismiss). Fast, non-LLM — `opBusy`.
|
||||||
|
const runRejectNationOp = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
if (!game) return;
|
||||||
|
setOpBusy(true);
|
||||||
|
try {
|
||||||
|
const { game: g } = await api.rejectNationOp(game.id, index);
|
||||||
|
applyState(g);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[game, applyState],
|
||||||
|
);
|
||||||
|
|
||||||
// Jump forward: the single LLM call that commits the pending round. Only this
|
// Jump forward: the single LLM call that commits the pending round. Only this
|
||||||
// sets `busy` (the adds above are fast and non-LLM).
|
// sets `busy` (the adds above are fast and non-LLM).
|
||||||
const runJump = useCallback(async () => {
|
const runJump = useCallback(async () => {
|
||||||
@@ -374,14 +553,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [game, applyState]);
|
}, [game, applyState]);
|
||||||
|
|
||||||
|
// Ask the advisor: a separate (slow) LLM call. It sets its own `advisorBusy`
|
||||||
|
// flag (distinct from the turn `busy`) and clears it in `finally`, so the flag
|
||||||
|
// is released on both success and error. Asking never advances a turn and
|
||||||
|
// never touches the turn `busy` state — "Jump forward" stays usable.
|
||||||
const askAdvisor = useCallback(
|
const askAdvisor = useCallback(
|
||||||
async (prompt?: string) => {
|
async (prompt?: string) => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
|
setAdvisorBusy(true);
|
||||||
try {
|
try {
|
||||||
const { suggestions } = await api.advisor(game.id, prompt);
|
const { suggestions } = await api.advisor(game.id, prompt);
|
||||||
setAdvisors(suggestions);
|
setAdvisors(suggestions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setAdvisorBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[game],
|
[game],
|
||||||
@@ -422,6 +608,29 @@ export default function App() {
|
|||||||
setAdvisors([]);
|
setAdvisors([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Delete a saved game. Removes it from the "Saved games" list, clears it if it
|
||||||
|
// was the remembered active game, and heads home if it's the one currently
|
||||||
|
// shown. The server also broadcasts an SSE `error` to any *other* open tab of
|
||||||
|
// this game, which the `useSse` `onError` handler above turns into a go-home.
|
||||||
|
const runDeleteGame = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteGame(id);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavedGames((prev) => prev.filter((g) => g.id !== id));
|
||||||
|
if (activeGameId === id) {
|
||||||
|
clearActiveGameId();
|
||||||
|
setActiveGameId(null);
|
||||||
|
}
|
||||||
|
if (game?.id === id) goHome();
|
||||||
|
toast.success('Game deleted');
|
||||||
|
},
|
||||||
|
[activeGameId, game, goHome],
|
||||||
|
);
|
||||||
|
|
||||||
// Expose the latest game + handlers to the mount-once hashchange listener.
|
// Expose the latest game + handlers to the mount-once hashchange listener.
|
||||||
latestRef.current = { game, goHome, loadGame };
|
latestRef.current = { game, goHome, loadGame };
|
||||||
|
|
||||||
@@ -434,12 +643,16 @@ export default function App() {
|
|||||||
game={game}
|
game={game}
|
||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={runAddAction}
|
onAddAction={runAddAction}
|
||||||
onAddDiplomacy={runAddDiplomacy}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
onRemoveQueueItem={runRemoveQueueItem}
|
onRemoveQueueItem={runRemoveQueueItem}
|
||||||
onAdvisor={askAdvisor}
|
onAdvisor={askAdvisor}
|
||||||
onJump={runJump}
|
onJump={runJump}
|
||||||
|
onConfirmNationOp={runConfirmNationOp}
|
||||||
|
onRejectNationOp={runRejectNationOp}
|
||||||
onHome={goHome}
|
onHome={goHome}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -455,6 +668,36 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (screen.name === 'scenario-form') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
<ScenarioForm busy={scenarioBusy} onBack={goHome} onGenerate={generateScenarioDraft} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screen.name === 'scenario-review') {
|
||||||
|
const { template, description, year, world, checkpoints } = screen;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
<CheckpointReview
|
||||||
|
template={template}
|
||||||
|
description={description}
|
||||||
|
year={year}
|
||||||
|
world={world}
|
||||||
|
checkpoints={checkpoints}
|
||||||
|
busy={scenarioBusy}
|
||||||
|
onBack={openScenarioForm}
|
||||||
|
onFinalize={(approved, nationId) =>
|
||||||
|
void finalizeScenario(template, description, year, world, approved, nationId)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
@@ -465,6 +708,8 @@ export default function App() {
|
|||||||
savedGames={savedGames}
|
savedGames={savedGames}
|
||||||
activeGameId={activeGameId}
|
activeGameId={activeGameId}
|
||||||
onResume={resumeGame}
|
onResume={resumeGame}
|
||||||
|
onDelete={runDeleteGame}
|
||||||
|
onCreateScenario={openScenarioForm}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -473,41 +718,67 @@ 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;
|
||||||
|
/** A nation-op confirm/reject is in flight — drives the dialog controls only. */
|
||||||
|
opBusy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAddAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onAddDiplomacy: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
onRemoveQueueItem: (index: number) => void;
|
onRemoveQueueItem: (index: number) => void;
|
||||||
onAdvisor: (p?: string) => void;
|
onAdvisor: (p?: string) => void;
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
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. Cleared when the user picks a nation via the manual Inspect
|
||||||
|
// select.
|
||||||
|
const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null);
|
||||||
|
const focusNation = useCallback(
|
||||||
|
(id: string, info?: { 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));
|
||||||
@@ -534,11 +805,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">
|
||||||
@@ -548,14 +831,18 @@ function GameScreen({
|
|||||||
playerNationId={game.playerNationId}
|
playerNationId={game.playerNationId}
|
||||||
nations={game.nations}
|
nations={game.nations}
|
||||||
presetId={game.presetId}
|
presetId={game.presetId}
|
||||||
onFocusNation={setViewingId}
|
unowned={game.unowned ?? []}
|
||||||
onMapReady={handleMapReady}
|
markers={game.markers ?? []}
|
||||||
|
regionStats={game.regionStats ?? {}}
|
||||||
|
onFocusNation={focusNation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
|
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
|
||||||
|
Fixed and non-movable, centered at the top a few px from the top edge. */}
|
||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
id="header"
|
id="header"
|
||||||
anchor="top-center"
|
dataTestId="top-bar"
|
||||||
|
pinned
|
||||||
collapsible={false}
|
collapsible={false}
|
||||||
title={
|
title={
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@@ -602,17 +889,24 @@ function GameScreen({
|
|||||||
{player?.name ?? game.playerNationId}
|
{player?.name ?? game.playerNationId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Three-state live status: advisor fetch in flight → "Advisor
|
||||||
|
thinking…"; else a turn in flight → "Simulating…"; else "Idle".
|
||||||
|
Both LLM-in-flight states share the amber spinner; idle is muted. */}
|
||||||
<span
|
<span
|
||||||
data-testid="status-indicator"
|
data-testid="status-indicator"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||||
busy
|
advisorBusy || busy
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
? 'border-primary/40 bg-primary/10 text-primary'
|
||||||
: 'border-border bg-muted/40 text-muted-foreground',
|
: 'border-border bg-muted/40 text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
|
{advisorBusy || busy ? (
|
||||||
{busy ? 'Simulating…' : 'Idle'}
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CircleDot className="size-3" />
|
||||||
|
)}
|
||||||
|
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -628,13 +922,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"
|
||||||
@@ -643,7 +940,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>
|
||||||
@@ -655,13 +959,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"
|
||||||
@@ -671,6 +981,8 @@ function GameScreen({
|
|||||||
{game.events.length}
|
{game.events.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
|
dock={dockOf('feed')}
|
||||||
|
onDockChange={(m) => setDockFor('feed', m)}
|
||||||
collapsed={!!collapsed.feed}
|
collapsed={!!collapsed.feed}
|
||||||
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
||||||
bodyClassName="h-[52vh]"
|
bodyClassName="h-[52vh]"
|
||||||
@@ -678,9 +990,10 @@ function GameScreen({
|
|||||||
<EventFeed events={game.events} nations={game.nations} inWindow />
|
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|
||||||
{/* Command deck ("Your move"), bottom-center — queue items, then commit. */}
|
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
|
||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
id="action"
|
id="action"
|
||||||
|
dataTestId="panel-action"
|
||||||
anchor="bottom-center"
|
anchor="bottom-center"
|
||||||
width={370}
|
width={370}
|
||||||
title="Your move"
|
title="Your move"
|
||||||
@@ -692,6 +1005,8 @@ function GameScreen({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
dock={dockOf('action')}
|
||||||
|
onDockChange={(m) => setDockFor('action', m)}
|
||||||
collapsed={!!collapsed.action}
|
collapsed={!!collapsed.action}
|
||||||
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
||||||
bodyClassName="max-h-[72vh] overflow-y-auto"
|
bodyClassName="max-h-[72vh] overflow-y-auto"
|
||||||
@@ -699,12 +1014,16 @@ function GameScreen({
|
|||||||
<ActionPanel
|
<ActionPanel
|
||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={onAddAction}
|
onAddAction={onAddAction}
|
||||||
onAddDiplomacy={onAddDiplomacy}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
onRemoveQueueItem={onRemoveQueueItem}
|
onRemoveQueueItem={onRemoveQueueItem}
|
||||||
onAdvisor={onAdvisor}
|
onAdvisor={onAdvisor}
|
||||||
onJump={onJump}
|
onJump={onJump}
|
||||||
|
onConfirmNationOp={onConfirmNationOp}
|
||||||
|
onRejectNationOp={onRejectNationOp}
|
||||||
inWindow
|
inWindow
|
||||||
/>
|
/>
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
@@ -721,39 +1040,6 @@ function GameScreen({
|
|||||||
>
|
>
|
||||||
<MapLegend regions={regions} />
|
<MapLegend regions={regions} />
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|
||||||
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
|
|
||||||
<FloatingWindow
|
|
||||||
id="zoom"
|
|
||||||
anchor="bottom-right"
|
|
||||||
width={88}
|
|
||||||
title="Map"
|
|
||||||
titleIcon={<MapIcon className="size-4" />}
|
|
||||||
offset={{ y: -28 }}
|
|
||||||
collapsed={!!collapsed.zoom}
|
|
||||||
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-1.5 p-2.5">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => mapApiRef.current?.zoomIn()}
|
|
||||||
aria-label="Zoom in"
|
|
||||||
data-testid="zoom-in"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => mapApiRef.current?.zoomOut()}
|
|
||||||
aria-label="Zoom out"
|
|
||||||
data-testid="zoom-out"
|
|
||||||
>
|
|
||||||
<Minus className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FloatingWindow>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
|
import { Loader2, MessageSquare, PenLine, Radar, Trash2, Zap } from 'lucide-react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
@@ -15,12 +15,24 @@ 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 NationOpDialog from './NationOpDialog';
|
||||||
import type { GameState, RoundItem } from '@shared/types';
|
import type { GameState, RoundItem } from '@shared/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: GameState;
|
game: GameState;
|
||||||
/** A jump (the only LLM call) is currently in flight — disable the commit. */
|
/** A jump (the only turn LLM call) is currently in flight — disable the commit. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/**
|
||||||
|
* An advisor fetch (a separate LLM call) is in flight. Distinct from `busy`:
|
||||||
|
* it gates only the advisor UI (the "Ask advisor" button + its hint) and must
|
||||||
|
* never disable "Jump forward" or the rest of the turn flow.
|
||||||
|
*/
|
||||||
|
advisorBusy: boolean;
|
||||||
|
/**
|
||||||
|
* A pending nation-op confirm/reject request is in flight. Distinct from
|
||||||
|
* `busy`: it gates only the nation-op dialog controls, never the turn flow.
|
||||||
|
*/
|
||||||
|
opBusy: boolean;
|
||||||
/** Advisor brainstorm suggestions from the last request. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
@@ -32,6 +44,14 @@ interface Props {
|
|||||||
onAdvisor: (prompt?: string) => void;
|
onAdvisor: (prompt?: string) => void;
|
||||||
/** Commit the whole pending round in a single LLM turn. */
|
/** Commit the whole pending round in a single LLM turn. */
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
/**
|
||||||
|
* Apply a pending nation op: `edits.name` overrides a rename's target name
|
||||||
|
* (or a merge's name); `edits.playerPart` picks the player's successor part
|
||||||
|
* in a split (default 0).
|
||||||
|
*/
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
/** Drop (reject) a pending nation op by its index. */
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
/**
|
/**
|
||||||
* Render without the outer card chrome (the surrounding floating window
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
* provides the border and the "Your move" title). Same content, same testids.
|
* provides the border and the "Your move" title). Same content, same testids.
|
||||||
@@ -59,12 +79,16 @@ function describeItem(item: RoundItem, nationName: (id?: string) => string): str
|
|||||||
export default function ActionPanel({
|
export default function ActionPanel({
|
||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
inWindow = false,
|
inWindow = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [tab, setTab] = useState('action');
|
const [tab, setTab] = useState('action');
|
||||||
@@ -74,6 +98,8 @@ export default function ActionPanel({
|
|||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
// Index of the queued item the user is about to remove, or null (dialog closed).
|
// Index of the queued item the user is about to remove, or null (dialog closed).
|
||||||
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
||||||
|
// Whether the nation-op review dialog is open.
|
||||||
|
const [opDialogOpen, setOpDialogOpen] = useState(false);
|
||||||
|
|
||||||
const others = Object.entries(game.nations)
|
const others = Object.entries(game.nations)
|
||||||
.filter(([id]) => id !== game.playerNationId)
|
.filter(([id]) => id !== game.playerNationId)
|
||||||
@@ -101,6 +127,7 @@ export default function ActionPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pending = game.pending;
|
const pending = game.pending;
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
// The item the confirmation dialog is about (re-derived from the live list,
|
// The item the confirmation dialog is about (re-derived from the live list,
|
||||||
// so if the queue changes while the dialog is open it tracks the current
|
// so if the queue changes while the dialog is open it tracks the current
|
||||||
// item at that index — or the dialog closes if the index went out of range).
|
// item at that index — or the dialog closes if the index went out of range).
|
||||||
@@ -194,10 +221,23 @@ export default function ActionPanel({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||||
|
disabled={advisorBusy}
|
||||||
data-testid="ask-advisor"
|
data-testid="ask-advisor"
|
||||||
>
|
>
|
||||||
Ask advisor
|
{advisorBusy && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{advisorBusy ? 'Thinking…' : 'Ask advisor'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* While the advisor (a separate LLM call) is thinking, mirror the
|
||||||
|
turn's "sim-hint" so the user knows the request is in flight. */}
|
||||||
|
{advisorBusy && (
|
||||||
|
<p
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
data-testid="advisor-hint"
|
||||||
|
>
|
||||||
|
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||||
|
The advisor is thinking… this can take a few seconds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{advisors.length > 0 && (
|
{advisors.length > 0 && (
|
||||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||||
{advisors.map((a, i) => (
|
{advisors.map((a, i) => (
|
||||||
@@ -305,6 +345,31 @@ export default function ActionPanel({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Pending nation ops proposed by the model — open to confirm or reject. */}
|
||||||
|
{ops.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpDialogOpen(true)}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="nation-op-trigger"
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5 text-sm hover:bg-amber-500/15"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-amber-300">
|
||||||
|
<PenLine className="size-4" />
|
||||||
|
{ops.length} nation {ops.length === 1 ? 'proposal' : 'proposals'} — review
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-300/70">confirm or reject</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<NationOpDialog
|
||||||
|
game={game}
|
||||||
|
open={opDialogOpen}
|
||||||
|
onOpenChange={setOpDialogOpen}
|
||||||
|
opBusy={opBusy}
|
||||||
|
onConfirm={onConfirmNationOp}
|
||||||
|
onReject={onRejectNationOp}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||||
{queuedLabel}
|
{queuedLabel}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ListChecks, Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import StatBar from './StatBar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { EVENT_COLORS, RELATION_COLORS } from '@/lib/colors';
|
||||||
|
import { relationFromScore, type TemplateMeta, type WorldDraft, type WorldNation } from '@shared/scenario';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The era template the draft was generated from (name/year for the header). */
|
||||||
|
template: TemplateMeta;
|
||||||
|
/** The free-text description the user wrote (shown as the world's context). */
|
||||||
|
description: string;
|
||||||
|
/** The effective start year (template default or the user's override). */
|
||||||
|
year: number;
|
||||||
|
/** The step-1 draft world to review and finalize. */
|
||||||
|
world: WorldDraft;
|
||||||
|
/** The advisor's proposed checkpoints (all approved by default). */
|
||||||
|
checkpoints: string[];
|
||||||
|
/** A finalize (LLM) call is in flight — disables the controls. */
|
||||||
|
busy?: boolean;
|
||||||
|
/** Back to the scenario form (a fresh attempt — the form's state resets). */
|
||||||
|
onBack: () => void;
|
||||||
|
/** Step 2: finalize with the approved checkpoints + the chosen nation to lead. */
|
||||||
|
onFinalize: (approvedCheckpoints: string[], playerNationId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAT_BARS: Array<{ key: keyof WorldNation['stats']; label: string }> = [
|
||||||
|
{ key: 'military', label: 'Military' },
|
||||||
|
{ key: 'economy', label: 'Economy' },
|
||||||
|
{ key: 'stability', label: 'Stability' },
|
||||||
|
{ key: 'population', label: 'Population' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Create scenario" — step 2 of the dynamic path (v0.31, #52). Shows the
|
||||||
|
* generated world (each nation's stats, territory country codes, and
|
||||||
|
* relations; plus any backstory events) and the advisor's proposed
|
||||||
|
* checkpoints as an approve/remove checklist (approved by default — the
|
||||||
|
* approved ones bind the finalize step). The player then picks which nation
|
||||||
|
* to lead (the nation cards mirror NationSelect's pick screen; the draft's
|
||||||
|
* nations are not a static Preset, so they are rendered here directly) and
|
||||||
|
* finalizes. On success the app navigates into the new game; on failure it
|
||||||
|
* toasts and stays here with the current selections intact.
|
||||||
|
*/
|
||||||
|
export default function CheckpointReview({
|
||||||
|
template,
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
world,
|
||||||
|
checkpoints,
|
||||||
|
busy = false,
|
||||||
|
onBack,
|
||||||
|
onFinalize,
|
||||||
|
}: Props) {
|
||||||
|
// Approve/remove per checkpoint (approved by default — the advisor's list is
|
||||||
|
// a starting point the player prunes, not a form they fill in).
|
||||||
|
const [approved, setApproved] = useState<boolean[]>(() => checkpoints.map(() => true));
|
||||||
|
// The nation the player will lead (none until explicitly picked).
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const nameOf = (id: string) => world.nations.find((n) => n.id === id)?.name ?? id;
|
||||||
|
const approvedCount = approved.filter(Boolean).length;
|
||||||
|
const selected = selectedId ? world.nations.find((n) => n.id === selectedId) : undefined;
|
||||||
|
|
||||||
|
const toggle = (i: number) =>
|
||||||
|
setApproved((prev) => prev.map((v, j) => (j === i ? !v : v)));
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
onFinalize(
|
||||||
|
checkpoints.filter((_, i) => approved[i]),
|
||||||
|
selectedId,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-review">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||||
|
← Back to form
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{template.name}</h1>
|
||||||
|
<Badge variant="secondary" className="text-sm">
|
||||||
|
{year}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- The generated world --- */}
|
||||||
|
<h2 className="mb-3 mt-8 text-lg font-semibold">The generated world</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{world.nations.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className="rounded-lg border border-border/60 bg-card/40 p-3"
|
||||||
|
data-testid="draft-nation"
|
||||||
|
data-nation-id={n.id}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{n.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs capitalize">
|
||||||
|
{n.type ?? 'major'}
|
||||||
|
</Badge>
|
||||||
|
{n.territory.length > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{n.territory.join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||||
|
{STAT_BARS.map(({ key, label }) => (
|
||||||
|
<StatBar key={key} label={label} value={n.stats[key]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Object.keys(n.relations).length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(n.relations).map(([otherId, score]) => {
|
||||||
|
const status = relationFromScore(score);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={otherId}
|
||||||
|
className="rounded-full border px-2 py-0.5 text-[11px] font-medium"
|
||||||
|
style={{
|
||||||
|
color: RELATION_COLORS[status],
|
||||||
|
borderColor: `${RELATION_COLORS[status]}55`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nameOf(otherId)} {score > 0 ? `+${score}` : score}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(world.events ?? []).length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
|
||||||
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Backstory
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(world.events ?? []).map((e, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm" data-testid="draft-event">
|
||||||
|
<span
|
||||||
|
className="mt-1.5 inline-block size-2 shrink-0 rounded-full"
|
||||||
|
style={{ background: EVENT_COLORS[e.type] }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium">{nameOf(e.nation)}:</span>{' '}
|
||||||
|
{e.title} — {e.text}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Advisor checkpoints (approve / remove) --- */}
|
||||||
|
<div className="mt-8 flex items-center gap-2">
|
||||||
|
<ListChecks className="size-4 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">Advisor checkpoints</h2>
|
||||||
|
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
|
||||||
|
{approvedCount} of {checkpoints.length} approved
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
The advisor’s opening read of this world’s live strategic threads. Keep the ones the final
|
||||||
|
world should respect — removed checkpoints are dropped.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{checkpoints.map((c, i) => (
|
||||||
|
<li
|
||||||
|
key={`${i}-${c}`}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg border p-2.5 text-sm',
|
||||||
|
approved[i]
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10'
|
||||||
|
: 'border-border/60 bg-card/30 text-muted-foreground',
|
||||||
|
)}
|
||||||
|
data-testid="checkpoint-item"
|
||||||
|
data-approved={approved[i] ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||||
|
approved[i] ? 'border-amber-500/50 bg-amber-500/20' : 'border-border',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{approved[i] && <Check className="size-3 text-amber-400" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 leading-snug">{c}</span>
|
||||||
|
<Button
|
||||||
|
variant={approved[i] ? 'ghost' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggle(i)}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="checkpoint-toggle"
|
||||||
|
>
|
||||||
|
{approved[i] ? 'Remove' : 'Keep'}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* --- Choose your nation (cards mirror NationSelect's pick screen) --- */}
|
||||||
|
<h2 className="mb-3 mt-8 text-lg font-semibold">Choose your nation</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{world.nations.map((n) => {
|
||||||
|
const isSel = n.id === selectedId;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={n.id}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col transition',
|
||||||
|
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
|
||||||
|
)}
|
||||||
|
data-testid="scenario-nation-card"
|
||||||
|
data-nation-id={n.id}
|
||||||
|
data-selected={isSel ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
{n.name}
|
||||||
|
{isSel && <Check className="size-4 shrink-0 text-primary" />}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{n.type ?? 'major'} · {n.territory.length} territory unit
|
||||||
|
{n.territory.length === 1 ? '' : 's'} ({n.territory.join(', ')})
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="mt-auto space-y-1.5">
|
||||||
|
{STAT_BARS.map(({ key, label }) => (
|
||||||
|
<StatBar key={key} label={label} value={n.stats[key]} />
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
className="mt-3 w-full"
|
||||||
|
variant={isSel ? 'default' : 'secondary'}
|
||||||
|
onClick={() => setSelectedId(n.id)}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="scenario-lead-nation"
|
||||||
|
>
|
||||||
|
{isSel ? 'Leading' : `Play as ${n.name}`}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Finalize --- */}
|
||||||
|
<div
|
||||||
|
className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3"
|
||||||
|
data-testid="finalize-bar"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-sm text-muted-foreground">
|
||||||
|
{selected
|
||||||
|
? `Finalizing as ${selected.name} with ${approvedCount} of ${checkpoints.length} checkpoints approved.`
|
||||||
|
: 'Pick the nation you will lead, then finalize the scenario.'}
|
||||||
|
</span>
|
||||||
|
<Button className="shrink-0" onClick={finalize} disabled={busy || !selected} data-testid="finalize-scenario">
|
||||||
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||||
|
{busy ? 'Finalizing…' : 'Finalize scenario'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, Loader2, PenLine, X } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import type { GameState } from '@shared/types';
|
||||||
|
import type { NationOp, RenameOp } from '@shared/protocol';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameState;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** A confirm/reject request is in flight — disable the per-op controls. */
|
||||||
|
opBusy: boolean;
|
||||||
|
/**
|
||||||
|
* Apply a pending op: `edits.name` overrides a rename's target name (or the
|
||||||
|
* merged name for a merge); `edits.playerPart` picks which new part the
|
||||||
|
* player's nation becomes in a split (default 0).
|
||||||
|
*/
|
||||||
|
onConfirm: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
/** Drop a pending op (reject / dismiss). */
|
||||||
|
onReject: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, display-safe description of a pending nation op. */
|
||||||
|
function describeOp(op: NationOp, name: (ref: string) => string): string {
|
||||||
|
if (op.kind === 'rename') return `Rename ${name(op.nationRef)} to “${op.to}”`;
|
||||||
|
if (op.kind === 'split')
|
||||||
|
return `Split ${name(op.nationRef)} into ${op.parts.length} nations (${op.parts
|
||||||
|
.map((p) => p.name)
|
||||||
|
.join(', ')})`;
|
||||||
|
return `Merge ${op.nationRefs.map(name).join(' + ')} into “${op.into}”`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
|
||||||
|
* and lets the player apply ("LLM proposes, player confirms") or drop each one:
|
||||||
|
* a rename shows the proposed name in an editable field; a split lists the
|
||||||
|
* proposed parts (with the number of regions each is assigned) and, when the
|
||||||
|
* split parent is the player's own nation, radio buttons to pick which new
|
||||||
|
* part the player leads; a merge shows the input nations (the survivor
|
||||||
|
* highlighted) and an editable name for the merged state (default `into`).
|
||||||
|
*/
|
||||||
|
export default function NationOpDialog({
|
||||||
|
game,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
opBusy,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
}: Props) {
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
|
const name = (ref: string) => game.nations[ref]?.name ?? ref;
|
||||||
|
// Resolve a nation ref the way the server does (id first, then a unique
|
||||||
|
// case-insensitive name; null when unknown/ambiguous) — UI hints only, the
|
||||||
|
// server stays authoritative.
|
||||||
|
const resolveRef = (ref: string): string | null => {
|
||||||
|
const r = (ref ?? '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
if (game.nations[r]) return r;
|
||||||
|
const matches = Object.values(game.nations).filter(
|
||||||
|
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||||
|
);
|
||||||
|
return matches.length === 1 ? matches[0].id : null;
|
||||||
|
};
|
||||||
|
// The player's edits, keyed by pending-op index: the rename/merge target
|
||||||
|
// name (an unset entry means "use the model's proposed name"), and which
|
||||||
|
// split part the player succeeds to (preselected 0 — the radio is
|
||||||
|
// "required" and always has a valid answer).
|
||||||
|
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||||
|
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" data-testid="nation-op-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<PenLine className="size-4 text-primary" />
|
||||||
|
Nation proposals
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The model proposed changes to nations. Confirm to apply them (you can edit a
|
||||||
|
name and choose your successor state in a split) or reject to leave the world
|
||||||
|
as it is.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{ops.length === 0 ? (
|
||||||
|
<p className="py-2 text-sm text-muted-foreground" data-testid="nation-op-empty">
|
||||||
|
Nothing pending — the model hasn’t proposed any nation changes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
|
||||||
|
{ops.map((op, i) => {
|
||||||
|
const isRename = op.kind === 'rename';
|
||||||
|
const isSplit = op.kind === 'split';
|
||||||
|
const isMerge = op.kind === 'merge';
|
||||||
|
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
|
||||||
|
// Split: is the parent the player's own nation? (the successor radio
|
||||||
|
// is offered only in that case — the engine ignores playerPart otherwise)
|
||||||
|
const splitParentIsPlayer =
|
||||||
|
isSplit && resolveRef((op as { nationRef: string }).nationRef) === game.playerNationId;
|
||||||
|
const playerPart = playerParts[i] ?? 0;
|
||||||
|
// Merge: the survivor (highlighted) and whether the proposal is
|
||||||
|
// even valid (2+ distinct inputs; the survivor among them).
|
||||||
|
const survivorId = isMerge ? resolveRef((op as { survivingRef: string }).survivingRef) : null;
|
||||||
|
const inputIds = isMerge
|
||||||
|
? new Set(
|
||||||
|
(op as { nationRefs: string[] }).nationRefs
|
||||||
|
.map(resolveRef)
|
||||||
|
.filter((x): x is string => x !== null),
|
||||||
|
)
|
||||||
|
: new Set<string>();
|
||||||
|
const mergeValid =
|
||||||
|
!isMerge || (inputIds.size >= 2 && (survivorId !== null && inputIds.has(survivorId)));
|
||||||
|
const mergeName = isMerge ? (edits[i] ?? (op as { into: string }).into).trim() : '';
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (isRename) onConfirm(i, finalName ? { name: finalName } : undefined);
|
||||||
|
else if (isMerge) onConfirm(i, mergeName ? { name: mergeName } : undefined);
|
||||||
|
else onConfirm(i, splitParentIsPlayer ? { playerPart } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${i}-${op.kind}`}
|
||||||
|
className="rounded-lg border border-border/70 bg-card/50 p-3"
|
||||||
|
data-testid="nation-op-item"
|
||||||
|
data-kind={op.kind}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start gap-2">
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[11px] capitalize">
|
||||||
|
{op.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 text-sm leading-snug">
|
||||||
|
{describeOp(op, name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRename ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as RenameOp).to}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isSplit ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(op as { parts: { name: string; territory?: string[] }[] }).parts.map((p, pi) => (
|
||||||
|
<div
|
||||||
|
key={pi}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
data-testid="nation-op-part"
|
||||||
|
data-part={pi}
|
||||||
|
>
|
||||||
|
{splitParentIsPlayer ? (
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`player-part-${i}`}
|
||||||
|
checked={playerPart === pi}
|
||||||
|
onChange={() => setPlayerParts((prev) => ({ ...prev, [i]: pi }))}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="size-3.5 shrink-0 accent-amber-400"
|
||||||
|
aria-label={`Make ${p.name} the player's successor state`}
|
||||||
|
data-testid="nation-op-player-part"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{p.territory?.length ?? 0} region{(p.territory?.length ?? 0) === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{splitParentIsPlayer && (
|
||||||
|
<p className="text-[11px] text-muted-foreground" data-testid="nation-op-split-hint">
|
||||||
|
Choose which new state you will lead after the split.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(op as { parts: unknown[] }).parts.length < 2 && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
This split names fewer than two parts — confirming will dismiss it
|
||||||
|
without changing the world.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
|
||||||
|
const isSurvivor = resolveRef(ref) === survivorId;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={ri}
|
||||||
|
variant={isSurvivor ? 'default' : 'secondary'}
|
||||||
|
className="text-[11px]"
|
||||||
|
data-testid="nation-op-input"
|
||||||
|
>
|
||||||
|
{name(ref)}
|
||||||
|
{isSurvivor ? ' (survivor)' : ''}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as { into: string }).into}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
{!mergeValid && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
This merge is not valid as proposed — confirming will dismiss it
|
||||||
|
without changing the world.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
import StatBar from './StatBar';
|
import StatBar from './StatBar';
|
||||||
import { nationColor } from '@shared/protocol';
|
import { nationColor } from '@shared/protocol';
|
||||||
import { RELATION_COLORS } from '@/lib/colors';
|
import { RELATION_COLORS } from '@/lib/colors';
|
||||||
|
import { keyToName } from '@/lib/regions';
|
||||||
import type { Nation, RelationStatus } from '@shared/types';
|
import type { Nation, RelationStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
||||||
@@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||||
const regions = nation.territory;
|
const regions = nation.territory;
|
||||||
|
|
||||||
|
// Human region names for the territory chips (region key -> name), loaded once
|
||||||
|
// (cached in @/lib/regions). A key with no known name falls back to the raw key.
|
||||||
|
const [keyNames, setKeyNames] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
keyToName()
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setKeyNames(m);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* names are cosmetic — a failed fetch just leaves the raw keys */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const identity = (
|
const identity = (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
@@ -95,15 +114,20 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
</h3>
|
</h3>
|
||||||
{regions.length > 0 ? (
|
{regions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{regions.map((code) => (
|
{regions.map((key) => {
|
||||||
<span
|
const label = keyNames[key] ?? key;
|
||||||
key={code}
|
return (
|
||||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
<span
|
||||||
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
key={key}
|
||||||
>
|
className="rounded border px-1.5 py-0.5 text-xs"
|
||||||
{code}
|
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
||||||
</span>
|
title={key}
|
||||||
))}
|
data-region-key={key}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No territory.</p>
|
<p className="text-sm text-muted-foreground">No territory.</p>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Play } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Play, Sparkles, 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,20 @@ 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;
|
||||||
|
/**
|
||||||
|
* (v0.31, #52) Open the "Create scenario" path (era template + description →
|
||||||
|
* AI-generated world → checkpoint review → a normal game). Omitted = the
|
||||||
|
* create-scenario card is hidden.
|
||||||
|
*/
|
||||||
|
onCreateScenario?: () => 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 +46,15 @@ export default function PresetPicker({
|
|||||||
savedGames = [],
|
savedGames = [],
|
||||||
activeGameId,
|
activeGameId,
|
||||||
onResume,
|
onResume,
|
||||||
|
onDelete,
|
||||||
|
onCreateScenario,
|
||||||
}: 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">
|
||||||
@@ -66,6 +90,51 @@ export default function PresetPicker({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{(
|
||||||
|
onCreateScenario
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
// (v0.31, #52) The dynamic path, first in the grid: it is the
|
||||||
|
// "any world you can describe" card, distinct from the static presets.
|
||||||
|
id: 'create',
|
||||||
|
title: 'Create scenario',
|
||||||
|
summary:
|
||||||
|
'Design your own world — pick an era, describe it, and the AI drafts the world and the advisor’s opening checkpoints for your review before you pick a nation to lead.',
|
||||||
|
badge: 'custom world',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
).map((c) => (
|
||||||
|
<Card
|
||||||
|
key={c.id}
|
||||||
|
className="flex flex-col border-primary/40 bg-primary/5 transition hover:ring-1 hover:ring-primary/50"
|
||||||
|
data-testid="create-scenario-card"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Sparkles className="size-4 shrink-0 text-primary" />
|
||||||
|
{c.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||||
|
{c.badge}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm leading-relaxed">{c.summary}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="mt-auto">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => onCreateScenario?.()}
|
||||||
|
disabled={loading}
|
||||||
|
data-testid="create-scenario"
|
||||||
|
>
|
||||||
|
Create scenario
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
<Card
|
<Card
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -90,7 +159,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 +169,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 +209,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Textarea } from './ui/textarea';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { TemplateMeta } from '@shared/scenario';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** A generate (LLM) call is in flight — disables the form's controls. */
|
||||||
|
busy?: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
/**
|
||||||
|
* Step 1: design the draft world + checkpoint list. The form hands over the
|
||||||
|
* selected template's full metadata (the review screen shows its name/year)
|
||||||
|
* plus the description and the effective start year.
|
||||||
|
*/
|
||||||
|
onGenerate: (template: TemplateMeta, description: string, year: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The year input is an empty string ("use the template's default") or a number. */
|
||||||
|
const YEAR_MIN = 1000;
|
||||||
|
const YEAR_MAX = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Create scenario" — step 1 of the dynamic path (v0.31, #52). The user picks
|
||||||
|
* one of the light era templates (a card each: name, default year, blurb),
|
||||||
|
* writes a free-text description of the world they want, and may override the
|
||||||
|
* start year (the field is pre-filled from the selected template; leaving it
|
||||||
|
* empty keeps the template's default). "Generate world" posts the draft
|
||||||
|
* request; on success the app moves to the checkpoint review screen, on
|
||||||
|
* failure it toasts and stays here (the form's local state is preserved).
|
||||||
|
*/
|
||||||
|
export default function ScenarioForm({ busy = false, onBack, onGenerate }: Props) {
|
||||||
|
const [templates, setTemplates] = useState<TemplateMeta[]>([]);
|
||||||
|
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
// Pre-filled from the selected template's default year (the user can edit).
|
||||||
|
const [yearText, setYearText] = useState('');
|
||||||
|
|
||||||
|
// Load the era-template manifest on mount (the form is the only consumer).
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api
|
||||||
|
.listScenarioTemplates()
|
||||||
|
.then((r) => alive && setTemplates(r.templates))
|
||||||
|
.catch((e: unknown) => alive && setTemplatesError(e instanceof Error ? e.message : String(e)));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selected = templates.find((t) => t.id === selectedId) ?? null;
|
||||||
|
const effectiveYear =
|
||||||
|
yearText.trim() === '' ? NaN : Number.parseInt(yearText.trim(), 10);
|
||||||
|
const yearValid =
|
||||||
|
Number.isInteger(effectiveYear) && effectiveYear >= YEAR_MIN && effectiveYear <= YEAR_MAX;
|
||||||
|
|
||||||
|
const canGenerate = !busy && selected !== null && description.trim() !== '' && yearValid;
|
||||||
|
|
||||||
|
const selectTemplate = (t: TemplateMeta) => {
|
||||||
|
setSelectedId(t.id);
|
||||||
|
// Selecting an era pre-fills the year with that era's default (the user
|
||||||
|
// can still edit it afterwards).
|
||||||
|
setYearText(String(t.year));
|
||||||
|
};
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
if (!canGenerate || !selected) return;
|
||||||
|
onGenerate(selected, description.trim(), effectiveYear);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-form">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||||
|
← Back to scenarios
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Create a scenario</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Design your own world: pick an era, describe what should be true, and the AI will draft the
|
||||||
|
world and an opening set of advisor checkpoints for your review. Nothing is saved until you
|
||||||
|
finalize.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 1 — the era template (a card each). */}
|
||||||
|
<h2 className="mb-3 mt-8 text-lg font-semibold">
|
||||||
|
1 · Pick an era <span className="font-normal text-muted-foreground">(required)</span>
|
||||||
|
</h2>
|
||||||
|
{templatesError ? (
|
||||||
|
<p className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
Could not load the era templates: {templatesError}
|
||||||
|
</p>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-border/60 bg-card/40 p-3 text-sm text-muted-foreground">
|
||||||
|
Loading era templates…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{templates.map((t) => {
|
||||||
|
const isSel = t.id === selectedId;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer flex-col transition',
|
||||||
|
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
|
||||||
|
)}
|
||||||
|
data-testid="template-card"
|
||||||
|
data-template-id={t.id}
|
||||||
|
data-selected={isSel ? 'true' : 'false'}
|
||||||
|
onClick={() => selectTemplate(t)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">{t.name}</CardTitle>
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||||
|
{t.year}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm leading-relaxed">
|
||||||
|
{t.eraDescription}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="mt-auto">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-xs',
|
||||||
|
isSel ? 'font-medium text-primary' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3.5" />
|
||||||
|
{isSel ? 'Selected' : 'Start from this era'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2 — the description (+ optional year override). */}
|
||||||
|
<h2 className="mb-3 mt-8 text-lg font-semibold">
|
||||||
|
2 · Describe your world <span className="font-normal text-muted-foreground">(required)</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/60 bg-card/40 p-4">
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="e.g. “A cold-war world where the space race decided the Cold War — two blocs orbit, and the arctic shipping lanes are the new flashpoint.”"
|
||||||
|
rows={4}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="scenario-description"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="scenario-year-input" className="shrink-0 text-sm">
|
||||||
|
Start year
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="scenario-year-input"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={yearText}
|
||||||
|
onChange={(e) => setYearText(e.target.value)}
|
||||||
|
placeholder={selected ? `default ${selected.year}` : 'pick an era first'}
|
||||||
|
min={YEAR_MIN}
|
||||||
|
max={YEAR_MAX}
|
||||||
|
disabled={busy || !selected}
|
||||||
|
className="w-32"
|
||||||
|
data-testid="scenario-year"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{yearValid ? `The game begins on ${effectiveYear}-01-01.` : 'Leave empty for the era default, or use a year between 1000 and 3000.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate. */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button className="h-11 w-full" onClick={generate} disabled={!canGenerate} data-testid="generate-scenario">
|
||||||
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||||
|
{busy ? 'Generating world…' : 'Generate world'}
|
||||||
|
</Button>
|
||||||
|
{busy && (
|
||||||
|
<p className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||||
|
The world-builder is thinking — this can take a few seconds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+622
-134
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,21 @@ import type {
|
|||||||
AddActionResponse,
|
AddActionResponse,
|
||||||
AddDiplomacyResponse,
|
AddDiplomacyResponse,
|
||||||
AdvisorResponse,
|
AdvisorResponse,
|
||||||
|
ConfirmNationOpResponse,
|
||||||
CreateGameResponse,
|
CreateGameResponse,
|
||||||
|
DeleteGameResponse,
|
||||||
GameSummary,
|
GameSummary,
|
||||||
|
RejectNationOpResponse,
|
||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
|
import type {
|
||||||
|
ScenarioCommitRequest,
|
||||||
|
ScenarioCommitResponse,
|
||||||
|
ScenarioDraftRequest,
|
||||||
|
ScenarioDraftResponse,
|
||||||
|
TemplateMeta,
|
||||||
|
} from '@shared/scenario';
|
||||||
|
|
||||||
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, {
|
||||||
@@ -33,6 +43,9 @@ export const api = {
|
|||||||
body: JSON.stringify({ presetId, playerNationId }),
|
body: JSON.stringify({ presetId, playerNationId }),
|
||||||
}),
|
}),
|
||||||
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
||||||
|
// Delete a saved game entirely. Fast, non-LLM. Returns { deleted: true }.
|
||||||
|
deleteGame: (id: string) =>
|
||||||
|
req<DeleteGameResponse>(`/api/games/${id}`, { method: 'DELETE' }),
|
||||||
// Every saved game as a lightweight summary, newest-updated first (for the
|
// Every saved game as a lightweight summary, newest-updated first (for the
|
||||||
// home screen's "Saved games" section / resume affordances).
|
// home screen's "Saved games" section / resume affordances).
|
||||||
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
||||||
@@ -56,6 +69,24 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ index }),
|
body: JSON.stringify({ index }),
|
||||||
}),
|
}),
|
||||||
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
|
||||||
|
// its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
|
||||||
|
// overrides the proposed name), a split partitions the nation's territory
|
||||||
|
// (`edits.playerPart` picks which part the player leads, default 0), and a
|
||||||
|
// merge folds its inputs into the survivor (`edits.name` overrides the
|
||||||
|
// merged name). Fast, non-LLM — no turn advance. Returns the updated game.
|
||||||
|
confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) =>
|
||||||
|
req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),
|
||||||
|
}),
|
||||||
|
// (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by index,
|
||||||
|
// without applying it. Fast, non-LLM — no turn advance. Returns the updated game.
|
||||||
|
rejectNationOp: (id: string, index: number) =>
|
||||||
|
req<RejectNationOpResponse>(`/api/games/${id}/nation-op/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index }),
|
||||||
|
}),
|
||||||
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
||||||
jump: (id: string) =>
|
jump: (id: string) =>
|
||||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||||
@@ -71,4 +102,24 @@ export const api = {
|
|||||||
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
||||||
`/api/games/${id}/map`,
|
`/api/games/${id}/map`,
|
||||||
),
|
),
|
||||||
|
// --- Dynamic scenario generation (v0.31, #52) ---------------------------
|
||||||
|
// The additive "Create scenario" path: a two-step LLM flow (design a draft
|
||||||
|
// world + checkpoint list, then finalize it into a saved, playable game).
|
||||||
|
// The era-template manifest for the form's cards (id / name / year / blurb).
|
||||||
|
listScenarioTemplates: () => req<{ templates: TemplateMeta[] }>('/api/scenarios/templates'),
|
||||||
|
// Step 1: design a draft world + an advisor checkpoint list from an era
|
||||||
|
// template + a free-text description. An LLM call — can be slow; a 502
|
||||||
|
// means the model is down or returned nothing usable (the caller toasts).
|
||||||
|
draftScenario: (body: ScenarioDraftRequest) =>
|
||||||
|
req<ScenarioDraftResponse>('/api/scenarios/draft', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
// Step 2: finalize the approved draft (constrained to the approved
|
||||||
|
// checkpoints) into a new, playable game and save it. Returns the id.
|
||||||
|
commitScenario: (body: ScenarioCommitRequest) =>
|
||||||
|
req<ScenarioCommitResponse>('/api/scenarios/commit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
|||||||
failure: '#ef4444',
|
failure: '#ef4444',
|
||||||
partial: '#f59e0b',
|
partial: '#f59e0b',
|
||||||
neutral: '#94a3b8',
|
neutral: '#94a3b8',
|
||||||
|
// (v0.30, #50) engine-emitted "invasion repelled" — a muted, darker red so it
|
||||||
|
// reads as a defensive failure distinct from a bright outright `failure`.
|
||||||
|
repelled: '#9f1239',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Client-side region-key data access. Fetches the two static JSON files served
|
||||||
|
// from public/ and builds the lookups the region-level UI needs:
|
||||||
|
// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner)
|
||||||
|
// - keyToName(): region key -> human name (to show region names in the dossier)
|
||||||
|
// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel
|
||||||
|
// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a
|
||||||
|
// hardcoded origin.
|
||||||
|
type GeoLike = {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let idKeyPromise: Promise<Record<string, string>> | null = null;
|
||||||
|
let keyNamePromise: Promise<Record<string, string>> | null = null;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** feature id (from regions.geo.json) -> region key (from region-keys.json). */
|
||||||
|
export function idToKey(): Promise<Record<string, string>> {
|
||||||
|
idKeyPromise ??= fetchJson<Record<string, string>>('/region-keys.json');
|
||||||
|
return idKeyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* region key -> human region name. Built by joining region-keys.json (id -> key)
|
||||||
|
* with regions.geo.json (id -> properties.name). A key with no known name is
|
||||||
|
* simply absent (callers fall back to the raw key).
|
||||||
|
*/
|
||||||
|
export function keyToName(): Promise<Record<string, string>> {
|
||||||
|
keyNamePromise ??= Promise.all([idToKey(), fetchJson<GeoLike>('/regions.geo.json')]).then(
|
||||||
|
([keys, geo]) => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = keys[id];
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (key && typeof name === 'string') out[key] = name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return keyNamePromise;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user