Compare commits

..
Author SHA1 Message Date
kamilkrzywda a93ece9474 feat(#50): make the four 0-100 stats consequential
CI / build + unit + e2e (pull_request) Successful in 2m8s
- Add a hard military conquest gate (canConquer, CONQUEST_MILITARY_MARGIN=15)
  enforced in applyTurnResult: a contested invasion is repelled when the
  attacker's military is more than 15 below the defender's; re-claims of
  unowned regions are never gated. Emits a new 'repelled' event.
- Document the soft levers in the system prompt: stability<30 + gathering
  marker -> non-military takeover; economy growth/decay drives other stats.
- Emit bounded frontierMilitary (per-neighbour balance) and takeoverRisks
  hints in the user payload (only when present).
- repelled outcome color (#9f1239); full unit test coverage; stub untouched.
- Bump to v0.30.0.

Fixes #50
2026-10-15 08:24:03 +00:00
kamilkrzywda 44e54470e2 Merge pull request 'v0.29.0: Per-region stat lever (regionChanges) + region-stat hover' (#49) from feat/46-region-stats into master
CI / build + unit + e2e (push) Successful in 2m5s
2026-10-15 03:10:07 +02:00
kamilkrzywda cdabf16052 feat(#46 PR 2): per-region stat lever (regionChanges) + region-stat hover
CI / build + unit + e2e (pull_request) Successful in 2m31s
v0.29.0 — the LLM can now target individual regions' stats via
TurnResult.regionChanges (set/add per stat, clamped 0-100), applied
after nation-wide stateChanges and before the weighted-median
recompute. The map hover tooltip shows each region's own four stats.
The stub stays deterministic (regionChanges: []).

Fixes #46 (PR 2 of 2)
2026-10-15 01:06:53 +00:00
kamilkrzywda 7fbc00ebbe Merge pull request 'v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2)' (#47) from feat/46-region-stats into master
CI / build + unit + e2e (push) Successful in 2m31s
2026-10-15 02:20:55 +02:00
kamilkrzywda 22805a3be6 v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2)
CI / build + unit + e2e (pull_request) Successful in 2m34s
2026-10-15 00:17:19 +00:00
kamilkrzywda 327fffb789 Merge pull request 'v0.27.0 (#44): LLM-placed named map point markers' (#45) from feat/44-llm-map-pointers into master
CI / build + unit + e2e (push) Successful in 2m41s
2026-10-15 00:40:52 +02:00
kamilkrzywda a129c2ab81 v0.27.0 (#44): LLM-placed named map point markers
CI / build + unit + e2e (pull_request) Successful in 2m36s
- shared: 8 MarkerTypes + MARKER_TYPE_INFO (label/color/glyph); MapMarker;
  GameState.markers (required, [] at init); TurnResult.markers {add,remove};
  MarkerSpec/MarkerInfo; FNV-1a 4-char markerId; MAX_MARKERS=400.
- data: new public/region-centroids.json (1,800 anchors = largest-part centroid
  + spread ~0.1x min-bbox) built by new idempotent scripts/build-region-centroids.mjs.
- server: regions.ts centroid loader (public/ then dist/); engine applyTurnResult
  marker ops (remove -> owner-recompute -> add, jitter, cap, never throws);
  prompt documents the contract; buildUserPrompt carries markers; stub marker-free.
- client: WorldMap symbol layer rendered as canvas per-type icons (glyph CDN dead);
  hover tooltip shows name; click focuses owning nation.
- tests: 168 -> 189 (centroids script, regions centroids, db legacy, engine markers).
2026-10-14 22:37:29 +00:00
kamilkrzywda aa1bd3c5aa Merge pull request 'Re-coarsen RU-ARK Arctic-coast band into RU-PEO + RU-YAM (#41)' (#42) from feat/41-split-arkhangel-band into master
CI / build + unit + e2e (push) Successful in 2m32s
2026-10-14 21:29:55 +02:00
kamilkrzywda f6ad554823 Re-coarsen RU-ARK Arctic-coast band into RU-PEO + RU-YAM (#41)
CI / build + unit + e2e (pull_request) Successful in 2m34s
#41: re-partition the RU-ARK mainland's 28-part lat>=68 Arctic-coast band
into two new regions via a new turf-free, idempotent pipeline stage
scripts/split-arkhangel-band.mjs (npm run split:regions:band), mirroring the
#31 split:

- RU-PEO (Pechora, 13 parts, ~37,678 km2) -> key pechora-qwhs
- RU-YAM (Yamal, 15 parts, ~51,722 km2) -> key yamal-gosw
- RU-ARK keeps its id/name/key arkhangel-sk-lhbk (ownership-safe); the 5-part
  lat<68 core (~305,229 km2) stays RU-ARK.

Pure re-partition: no geometry moved (byte-identical union). The two new
features are inserted immediately after RU-ARK-SZ. Manifests regenerated with
the real scripts (1,798 -> 1,800 keys + adjacency; adjacency pairs 4,471 ->
4,473). Fixed-point dataset unchanged except feature count (11,794 parts /
11,795 rings / 164,801 verts / 1 KZ-KZY hole). Four dataset/manifest tests
updated (RUS 82 -> 84 sub-regions). Version 0.25.0 -> 0.26.0.

Fixes #41
2026-10-14 19:26:49 +00:00
kamilkrzywda 867b2eb0a1 Merge pull request 'feat: split RU-ARK into mainland + New Earth + Severnaya Zemlya (v0.25.0, #31)' (#40) from feat/31-split-arkhangel into master
CI / build + unit + e2e (push) Failing after 14m43s
2026-10-14 19:57:50 +02:00
kamilkrzywda 8fb8984d07 chore: bump to v0.25.0 (#31) + CHANGELOG
CI / build + unit + e2e (pull_request) Successful in 2m34s
2026-10-14 17:54:45 +00:00
kamilkrzywda f593af5ba5 feat: split RU-ARK into mainland + New Earth + Severnaya Zemlya — idempotent stage, manifests 1,798 (v0.25.0, #31) 2026-10-14 17:54:45 +00:00
kamilkrzywda c63f6acca1 Merge pull request 'feat: nation-op split/merge — apply on confirm (v0.24.0, #16 P4b)' (#39) from feat/16-p4b-split-merge into master
CI / build + unit + e2e (push) Successful in 2m30s
2026-10-14 15:49:31 +02:00
kamilkrzywda c0c620a54c docs: v0.24.0 changelog — #16 was already closed; reference it (no Fixes), matching P4a's PR #38
CI / build + unit + e2e (pull_request) Successful in 2m31s
2026-10-14 13:46:16 +00:00
kamilkrzywda 7eaed42d51 chore: bump to v0.24.0 (nation-op split/merge, #16 P4b) + CHANGELOG 2026-10-14 13:42:46 +00:00
kamilkrzywda 14c5d4881d feat: nation-op split/merge review — dialog (part list + successor radio, merge name), docs (P4b, #16) 2026-10-14 13:42:46 +00:00
kamilkrzywda 458e9be419 feat: nation-op split/merge — apply on confirm (playerPart, fresh ids, relations union) (P4b, #16) 2026-10-14 13:42:46 +00:00
kamilkrzywda f72a59bf5e Merge pull request 'feat: nation-op rename — LLM proposes, player confirms (v0.23.0, #16 P4a)' (#38) from feat/16-p4a-rename into master
CI / build + unit + e2e (push) Successful in 2m27s
2026-10-14 15:03:41 +02:00
kamilkrzywda 6ebe21591e chore: bump to v0.23.0 (nation-op rename, #16 P4a) + CHANGELOG
CI / build + unit + e2e (pull_request) Successful in 2m26s
2026-10-14 13:00:33 +00:00
kamilkrzywda 9f230606c4 feat: nation-op rename review — confirm/reject routes, client dialog, AGENTS.md (P4a, #16)
Wire the 'LLM proposes, player confirms' nation-rename flow end to end:
- server: POST /api/games/:id/nation-op/confirm|reject (404/400, version-bumped SSE state)
- client: confirmNationOp/rejectNationOp api helpers + NationOpDialog (editable rename) wired into the Action panel via a 'Nation proposals' trigger
- AGENTS.md: document the pendingNationOps mechanism, routes, nationRef resolution, stub-never-proposes
2026-10-14 13:00:28 +00:00
kamilkrzywda e9fa267fd0 Nation-op proposals (P4a, #16): protocol types, pendingNationOps state, engine propose/resolve (stage 1)
- shared/protocol.ts: RenameOp/SplitOp/MergeOp + NationOp union; TurnResult.nationOps (required); ConfirmNationOp/RejectNationOp request+response types
- shared/types.ts: GameState.pendingNationOps (type-only import of NationOp — no runtime cycle)
- server/engine.ts: validateNationOps parser wired into validateTurnResult; isMeaningful counts proposals; applyTurnResult appends to the surviving pending list (never applies); runTurn fallback literal; system prompt gains the nationOps JSON field + rename proposal rule (both modes); new resolveNationRef/applyNationOp/confirmNationOp/rejectNationOp (id-first then unique case-insensitive name; rename changes Nation.name only; split/merge no-op until P4b; index validation returns null for 400)
- server/db.ts: legacy rows load with pendingNationOps defaulted to []
- server/index.ts: fresh games start with pendingNationOps: []
- server/stub.ts: deterministicTurn returns nationOps: [] (the stub never proposes — unit-only coverage, matching P3)
- tests: fixture updates (engine/db/map/regions) + new suites for resolveNationRef, validateNationOps, proposal lifecycle, confirm/reject (151 tests total)
2026-10-14 12:47:58 +00:00
kamilkrzywda edaa8f55d1 Merge pull request 'No man's land: region release + re-claim + distinct grey map rendering (v0.22.0) — part 3 of #16' (#37) from feat/16-p3-no-mans-land into master
CI / build + unit + e2e (push) Successful in 2m48s
2026-10-14 14:09:22 +02:00
kamilkrzywda 6c52e10898 Bump to v0.22.0 (no man's land, #16 P3) + AGENTS.md/CHANGELOG
CI / build + unit + e2e (pull_request) Successful in 1m33s
2026-10-14 12:04:03 +00:00
kamilkrzywda 764f24fc55 No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map
  presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey)
- shared/types.ts: required GameState.unowned (cumulative mid-game releases;
  [] at init, distinct from preset-unassigned regions)
- server/engine.ts: applyTurnResult applies releases first (sole-owner only,
  never fabricating no man's land from unassigned keys), then claims against
  the post-release snapshot (gain on an ownerless key = re-claim); lone
  release is isMeaningful; prompts + user payload carry the unowned field
- server/stub.ts: unchanged behavior (never releases; e2e stays stable)
- server/db.ts: legacy rows load with unowned defaulted to []
- server/index.ts: create-game sets []; jump reference table includes
  released keys so the model can name them for re-claim
- WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip
- unit tests: 105 -> 127 (release/re-claim suites, presentation contract,
  legacy-row normalization)
2026-10-14 12:04:03 +00:00
39 changed files with 23030 additions and 150 deletions
+183 -21
View File
@@ -28,13 +28,17 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
choropleth from `public/regions.geo.json` (1,796 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
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.
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
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
@@ -52,31 +56,44 @@ history/
├── eslint.config.js # flat config (ESLint 9)
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 105 tests)
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 225 tests)
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest)
├── public/
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
│ ├── regions.geo.json # 1,796 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
│ ├── region-keys.json # feature id -> region key, 1,796 entries (the ownership/LLM address space, #16 P1)
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2)
│ ├── 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)
│ └── 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 → build-region-keys → build-region-adjacency
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
│ │ # (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
│ ├── 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/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
│ │ # PORT/LLM_*/DATA_DIR
│ ├── 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)
│ ├── 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 (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
│ ├── 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
│ ├── 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)
└── src/
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
@@ -91,7 +108,8 @@ history/
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
├── 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)
├── 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
@@ -105,11 +123,15 @@ history/
| `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 start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 105 tests) |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 225 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `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 build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `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 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` |
**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.
@@ -171,7 +193,140 @@ history/
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.
`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).
- **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
@@ -194,9 +349,15 @@ history/
date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the
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. 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.
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**
@@ -286,7 +447,8 @@ history/
(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; the `public/` data files
(`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the
ownership/adjacency manifests — and `countries.geo.json`) are copied into `dist/` by Vite at
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
+125
View File
@@ -2,6 +2,131 @@
All notable changes to **History** are listed here, newest first.
## 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.
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.21.0",
"version": "0.30.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -14,8 +14,13 @@
"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",
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"e2e": "playwright test"
+251
View File
@@ -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
}
+13
View File
@@ -7587,6 +7587,7 @@
"nenets-qyga": [
"arkhangel-sk-lhbk",
"komi-eoeh",
"pechora-qwhs",
"yamal-nenets-aqsx"
],
"neuquen-yvne": [
@@ -7614,6 +7615,7 @@
"ontario-bzqu",
"quebec-yegk"
],
"new-earth-eikt": [],
"new-hampshire-koki": [
"maine-maiz",
"massachusetts-ggid",
@@ -8748,6 +8750,10 @@
"soriano-ixbv",
"tacuarembo-yekg"
],
"pechora-qwhs": [
"nenets-qyga",
"yamal-gosw"
],
"peloponnisos-wqyw": [
"dytiki-ellada-mlyj",
"sterea-ellada-inyu"
@@ -10010,6 +10016,9 @@
"medea-elbf",
"skikda-odba"
],
"severnaya-zemlya-awqp": [
"yamal-gosw"
],
"sevilla-oxma": [
"badajoz-ciwt",
"cadiz-aazc",
@@ -12160,6 +12169,10 @@
"iwate-uvfz",
"niigata-idhx"
],
"yamal-gosw": [
"pechora-qwhs",
"severnaya-zemlya-awqp"
],
"yamal-nenets-aqsx": [
"khanty-mansiy-tkto",
"komi-eoeh",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1305,6 +1305,10 @@
"RU-ALT": "altay-mfuq",
"RU-AMU": "amur-clbp",
"RU-ARK": "arkhangel-sk-lhbk",
"RU-ARK-NE": "new-earth-eikt",
"RU-ARK-SZ": "severnaya-zemlya-awqp",
"RU-PEO": "pechora-qwhs",
"RU-YAM": "yamal-gosw",
"RU-AST": "astrakhan-anec",
"RU-BA": "bashkortostan-uovk",
"RU-BEL": "belgorod-wxjk",
File diff suppressed because one or more lines are too long
+132
View File
@@ -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`,
);
+165
View File
@@ -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')}`,
);
+4 -4
View File
@@ -21,7 +21,7 @@
* 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,796 regions.
* 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
@@ -107,8 +107,8 @@ 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 !== 1796) {
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
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
@@ -154,5 +154,5 @@ 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,796 expected)`);
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(', ')}`);
+78
View File
@@ -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)`,
);
+29 -1
View File
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
const adjacencyKeys = new Set(Object.keys(adjacency));
expect(adjacencyKeys).toEqual(manifestKeys);
expect(Object.keys(adjacency)).toHaveLength(keys.size);
expect(adjacencyKeys.size).toBe(1796);
expect(adjacencyKeys.size).toBe(1800);
});
it('every neighbour is itself a valid region key (referential integrity)', () => {
@@ -84,4 +84,32 @@ describe('region-adjacency.json', () => {
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',
]);
});
});
+158
View File
@@ -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);
});
});
+139
View File
@@ -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);
});
});
+20 -5
View File
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
const keys = Object.values(manifest);
const featureIds = (geo.features ?? []).map((f) => String(f.id));
it('has exactly 1,796 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1796);
it('has exactly 1,800 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1800);
});
it('has 1,796 unique keys (no collisions)', () => {
it('has 1,800 unique keys (no collisions)', () => {
expect(new Set(keys).size).toBe(keys.length);
expect(new Set(keys).size).toBe(1796);
expect(new Set(keys).size).toBe(1800);
});
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
@@ -50,11 +50,26 @@ describe('region-keys.json', () => {
}
});
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(1796);
expect(featureIds).toHaveLength(1800);
const manifestIds = Object.keys(manifest);
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
});
+70 -12
View File
@@ -530,16 +530,22 @@ describe('regions.geo.json', () => {
);
});
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
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 region count (1,796), the
// per-feature ids/names/parents and their order are unchanged; only the
// part/ring/vertex totals and the hole's part structure moved. Pinning
// the signature guards against a non-idempotent or destructive re-run.
// The fill is byte-deterministic per input and idempotent (a second run
// on the filled file changes nothing).
// 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,
@@ -556,15 +562,67 @@ describe('regions.geo.json', () => {
for (const ring of poly) verts += ring.length;
}
}
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
expect(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
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.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
`\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)`,
);
});
});
+247
View File
@@ -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, …)`,
);
+180
View File
@@ -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}`);
+74
View File
@@ -23,6 +23,14 @@ function makeState(id: string): GameState {
events: [],
pending: [],
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 },
},
};
}
@@ -73,6 +81,72 @@ describe('openDb', () => {
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('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+18 -1
View File
@@ -2,6 +2,7 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types';
export interface Db {
@@ -39,7 +40,23 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined;
if (!row) return null;
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.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 {
return null;
}
+1827 -16
View File
File diff suppressed because it is too large Load Diff
+951 -48
View File
File diff suppressed because it is too large Load Diff
+81 -4
View File
@@ -8,19 +8,21 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import { deepClone, seedRegionStats } from '../shared/protocol';
import type {
AddActionRequest,
AddDiplomacyRequest,
ConfirmNationOpRequest,
CreateGameRequest,
RejectNationOpRequest,
RemoveQueueItemRequest,
} from '../shared/protocol';
import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validKeys } from './regions';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -124,11 +126,28 @@ app.post('/api/games', (req: Request, res) => {
events: [],
pending: [],
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.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);
res.json({ game: state });
});
@@ -220,6 +239,38 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
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
// 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.
@@ -235,12 +286,38 @@ app.post('/api/games/:id/jump', async (req, res) => {
preset,
state: game,
items: game.pending,
regionReference: referenceForKeys(allTerritoryKeys(game)),
// (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),
});
db.save(state);
broadcast(id, 'turn', { state, result });
+73 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } 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';
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
@@ -35,6 +47,11 @@ const state: GameState = {
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
regionStats: {},
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
@@ -49,6 +66,16 @@ describe('buildRegionMap', () => {
// a region nobody owns is simply absent
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', () => {
@@ -143,3 +170,48 @@ describe('nationColor', () => {
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');
});
});
+7
View File
@@ -3,6 +3,13 @@
// 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 type { GameState } from '../shared/types';
+259
View File
@@ -0,0 +1,259 @@
import { describe, expect, it } from 'vitest';
import {
canConquer,
CONQUEST_MILITARY_MARGIN,
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'],
};
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));
});
});
// --- 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
});
});
+93 -4
View File
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import {
allTerritoryKeys,
centroidOf,
expandCountriesToKeys,
geoOf,
hasAdjacency,
hasCentroids,
hasGeo,
hasRegionData,
loadRegions,
neighboursOf,
@@ -27,9 +31,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(loadRegions(root)).toBe(true);
});
it('loads the full manifest: 1,796 unique valid region keys', () => {
it('loads the full manifest: 1,800 unique valid region keys', () => {
expect(hasRegionData()).toBe(true);
expect(validKeys().size).toBe(1796);
expect(validKeys().size).toBe(1800);
});
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
@@ -51,6 +55,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
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)');
@@ -86,6 +105,11 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
regionStats: {},
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
@@ -116,6 +140,34 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
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();
});
describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions());
@@ -129,6 +181,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// 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();
});
});
@@ -164,13 +222,13 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
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(1796);
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(1796);
expect(validKeys().size).toBe(1800);
});
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
@@ -180,6 +238,37 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
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();
});
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', () => {
+127 -6
View File
@@ -2,7 +2,9 @@
// (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) — all
// 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:
@@ -13,6 +15,10 @@
// - 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)
//
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
@@ -39,12 +45,17 @@ interface RegionData {
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 }>;
}
let data: RegionData | null = null;
/**
* Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
* 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/`
@@ -52,7 +63,10 @@ let data: RegionData | null = null;
* 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). Returns true when the core pair loaded
* 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).
@@ -61,6 +75,8 @@ 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 base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
@@ -74,13 +90,13 @@ export function loadRegions(root: string): boolean {
const geoPath = path.join(base, geoFile);
try {
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
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 geo.features ?? []) {
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
@@ -105,7 +121,71 @@ export function loadRegions(root: string): boolean {
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, 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 = {};
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
return validKeys.size > 0;
} catch (err) {
console.error('[regions] failed to load region data', err);
@@ -189,6 +269,47 @@ 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];
}
/**
* 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
+36
View File
@@ -24,6 +24,20 @@ export interface StubPayload {
* 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 }>;
}
function truncate(s: string, n: number): string {
@@ -45,6 +59,24 @@ function truncate(s: string, n: number): string {
* 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.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -106,8 +138,12 @@ export function deterministicTurn(p: StubPayload): TurnResult {
date: addDays(state?.date ?? '1939-09-01', 2),
events,
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' }] : [],
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}
+475
View File
@@ -6,16 +6,37 @@ import type {
EventOutcome,
EventType,
GameState,
MarkerType,
Nation,
NationStats,
RelationStatus,
RegionStats,
StatKey,
} from './types';
import { MARKER_TYPES, STAT_KEYS } from './types';
/** A numeric change to one of a nation's 0–100 stats. */
export type StateChange =
| { nation: string; stat: StatKey; op: 'set'; value: number }
| { nation: string; stat: StatKey; op: 'add'; value: number };
/**
* (v0.29, #46 PR 2) A numeric change to one of **one region's** 0–100 stats —
* the fine-grained, per-region lever that complements the nation-wide
* {@link StateChange}. `region` names the target by its **region key**
* (preferred) or its human-readable **name** (the engine resolves it
* case-insensitively, mirroring `resolveNationRef`); after validation it holds
* the resolved **region key**. `op` is `set` (replace the value) or `add`
* (delta, clamped to 0–100 by the engine).
*/
export type RegionStatChange = {
/** The target region's key (preferred) or name (resolved by the engine). */
region: string;
stat: StatKey;
op: 'set' | 'add';
value: number;
};
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
export interface RelationChange {
a: string;
@@ -30,6 +51,81 @@ export interface TerritoryChange {
lose: string[];
}
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
// proposes, player confirms" model) ------------------------------------------
//
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
// NOT applied by that turn. It is stored as a *pending proposal* in
// `GameState.pendingNationOps` and only takes effect when the player confirms
// it (optionally editing it) or is dropped when the player rejects it. All
// three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
/**
* The identity reference a nation is addressed by inside a {@link NationOp}.
* The LLM may use either the nation's stable `id` (the `nations` record key,
* e.g. `f`) or its human-readable `name` (e.g. `France`); the engine resolves
* it to a concrete `nationId` (id first, then a unique name match — see the
* engine's `resolveNationRef`).
*/
/**
* A **rename** proposal: change a nation's human-readable `name`. Only the
* `name` changes — the stable `id`, its territory keys, its relations (keyed
* by id), and the player pointer are all left untouched, so nothing downstream
* breaks. `to` is the new display name.
*/
export interface RenameOp {
kind: 'rename';
/** The target nation's id or current name (see the note above). */
nationRef: string;
/** The new display name to apply when the player confirms. */
to: string;
}
/**
* A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
* nation is divided into. On confirm the source is dissolved: each part gets a
* fresh id, the region keys it was given (restricted to keys the source
* actually owns — first claim wins), fresh baseline stats, and a copy of the
* source's relations; any of the source's unclaimed keys go to `parts[0]`.
* When the source is the player's own nation the player chooses which part
* they lead (the confirm request's `edits.playerPart`, default `parts[0]`).
*/
export interface SplitOp {
kind: 'split';
/** The id/name of the nation being split. */
nationRef: string;
/** The new nations created from the split. */
parts: Array<{ name: string; territory?: string[] }>;
}
/**
* A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
* surviving nation. On confirm the survivor keeps its id (renamed to `into`,
* or to the confirm request's `edits.name`), its territory becomes the union
* of all inputs' territories, it keeps its own stats, and it inherits the
* union of all inputs' relations (its own winning per third party, else the
* first input's); every non-survivor input is removed. If the player's nation
* was a non-survivor input the player pointer is re-pointed to the survivor.
*/
export interface MergeOp {
kind: 'merge';
/** The id/name of every nation folded into the result. */
nationRefs: string[];
/** The id/name of the nation that survives and keeps its identity. */
survivingRef: string;
/** The name to give the merged nation. */
into: string;
}
/**
* A structural operation on the set of nations that the LLM may propose in a
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid
* propose→confirm semantics. All three kinds are implemented: rename (P4a),
* and split / merge (P4b, v0.24).
*/
export type NationOp = RenameOp | SplitOp | MergeOp;
/** One narrative event the LLM proposes for this turn. */
export interface TurnEventSpec {
nation: string;
@@ -39,13 +135,148 @@ export interface TurnEventSpec {
outcome: EventOutcome;
}
// --- Map markers (v0.27, #44) -------------------------------------------------
//
// The LLM places named point markers (cities, factories, armies, ports,
// capitals, fleets, rocket launch sites, gatherings) anchored to a region
// key. It never sees or sets coordinates: the engine derives the position
// from the region's precomputed centroid (public/region-centroids.json) plus a
// small deterministic offset, and it recomputes each marker's owner from the
// region's current owner on every turn (a released region → null).
//
// The marker id is a STABLE, deterministic 4-letter code (FNV-1a 32-bit →
// base 26, the same scheme as the region keys' 4-char suffix) derived from
// (type, name, regionKey) — so re-adding an identical marker is idempotent
// (same id, skipped) and removing by id is unambiguous.
/** The spec the LLM emits to place a marker: type, name and the region it sits in. */
export interface MarkerSpec {
type: MarkerType;
name: string;
regionKey: string;
}
/**
* One existing marker as the user prompt presents it to the model (v0.27,
* #44): its stable `id` (the model may echo it in `TurnResult.markers.remove`),
* type, name, region key, and the id of the nation currently owning its
* region (`null` when the region is unowned / no man's land).
*/
export interface MarkerInfo {
id: string;
type: MarkerType;
name: string;
regionKey: string;
owner: string | null;
}
/**
* The per-type presentation of a marker: its display label (tooltip /
* dossier), its color, and its glyph character (a symbol-layer text label —
* a small, font-safe character in front of the name). Pure + shared so the
* client's marker layer and any server-side consumers agree on the look.
*/
export const MARKER_TYPE_INFO: Record<MarkerType, { label: string; color: string; glyph: string }> = {
city: { label: 'City', color: '#cbd5e1', glyph: '●' },
factory: { label: 'Factory', color: '#fb923c', glyph: '■' },
army: { label: 'Army', color: '#ef4444', glyph: '▲' },
port: { label: 'Port', color: '#38bdf8', glyph: '◆' },
capital: { label: 'Capital', color: '#fbbf24', glyph: '★' },
fleet: { label: 'Fleet', color: '#22d3ee', glyph: '◗' },
rocket: { label: 'Rocket site', color: '#a78bfa', glyph: '↥' },
gathering: { label: 'Gathering', color: '#4ade80', glyph: '◉' },
};
/** Hard cap on a game's total markers (new adds beyond the cap are dropped, v0.27 #44). */
export const MAX_MARKERS = 400;
/**
* FNV-1a 32-bit — the same tiny, stable, pure string hash the region keys use
* (see scripts/build-region-keys.mjs). Exported so the engine can derive
* deterministic per-axis jitter fractions from it (marker placement, v0.27 #44).
*/
export function fnv1a32(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
function code4(h: number): string {
let out = '';
for (let i = 0; i < 4; i++) {
out += String.fromCharCode(97 + (((h >>> (i * 8)) & 0xff) % 26));
}
return out;
}
/**
* The stable id for a marker: 4 base-26 lowercase chars from the FNV-1a hash
* of `(type, name, regionKey)` (NUL-separated so no field boundary is
* ambiguous). Deterministic across runs and platforms (pure string math), so
* the same (type, name, regionKey) always maps to the same id — re-adding is
* idempotent, removing by id is unambiguous.
*/
export function markerId(type: MarkerType, name: string, regionKey: string): string {
return code4(fnv1a32(`${type}\u0000${name}\u0000${regionKey}`));
}
/** True when `t` is one of the eight known marker types (v0.27, #44). */
export function isMarkerType(t: unknown): t is MarkerType {
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
events: TurnEventSpec[];
stateChanges: StateChange[];
/**
* (v0.29, #46 PR 2) Fine-grained, **per-region** stat changes: each targets a
* single region's 0–100 stats (by region key or name) and complements the
* nation-wide `stateChanges` (which apply to *all* of a nation's regions).
* The engine resolves each `region` to a key (key first, then a
* case-insensitive unique name match) and applies it to
* `GameState.regionStats` only for regions that have an entry (owned); an
* unowned / no-man's-land region is a no-op. Empty on turns where the model
* changes no individual region. Required (default `[]`).
*/
regionChanges: RegionStatChange[];
relations: RelationChange[];
territoryChanges: TerritoryChange[];
/**
* (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" —
* this turn. The engine removes each key from its current (sole) owner so it
* ends the turn ownerless and marks it in `GameState.unowned`; the LLM
* narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is
* NOT expressed here: a nation re-claims an unowned region with an ordinary
* `gain` on that key. Only keys a nation currently owns can be released;
* empty on normal turns.
*/
unowned: string[];
/**
* (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
* turn. These are NOT applied by the turn: each is appended to
* `GameState.pendingNationOps` as a proposal awaiting the player's
* confirm/reject. All three kinds (rename, split, merge) are implemented —
* a confirmed rename changes `Nation.name`; a confirmed split dissolves the
* source into its parts; a confirmed merge folds its inputs into the
* survivor. Empty on turns where the model proposes none.
*/
nationOps: NationOp[];
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
* deterministic offset, sets the owner to the region's post-turn owner,
* derives the stable id, and drops unknown types/region keys, duplicates,
* and adds beyond MAX_MARKERS); `remove` drops markers by their stable id
* (a no-op for an id that is not present). Both required, `[]` on turns
* with no marker activity.
*/
markers: { add: MarkerSpec[]; remove: string[] };
}
/** Per-region map payload: region key -> owning nation + its stable color. */
@@ -152,6 +383,39 @@ export interface RemoveQueueItemResponse {
game: GameState;
}
/**
* (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
* index in `game.pendingNationOps`. A rename takes effect (the player may
* override the proposed name via `edits.name`); a split and a merge are fully
* applied (v0.24 P4b). No LLM call, no turn advance.
*/
export interface ConfirmNationOpRequest {
index: number;
/**
* Optional player edits applied on confirm.
* - `name`: the final display name — overrides a rename's `to`, a merge's
* `into`, or (for a split) the name of the part the player leads.
* - `playerPart`: for a split of the player's own nation, which part (index
* into the op's `parts`) the player will lead (default `0` when missing or
* out of range, so the player is never orphaned).
*/
edits?: { name?: string; playerPart?: number };
}
export interface ConfirmNationOpResponse {
game: GameState;
}
/**
* (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index
* in `game.pendingNationOps` without applying it. No LLM call, no turn advance.
*/
export interface RejectNationOpRequest {
index: number;
}
export interface RejectNationOpResponse {
game: GameState;
}
export interface AdvisorRequest {
/** Optional prompt to steer the brainstorm. */
prompt?: string;
@@ -188,6 +452,83 @@ export function nationColor(id: string): string {
return `hsl(${hue} 58% 46%)`;
}
// --- Combat: the military hard gate (v0.30, #50) ------------------------------
//
// The one rule the engine enforces on the player's behalf instead of trusting
// the LLM: a contested conquest only succeeds when the attacker is not MORE
// than `CONQUEST_MILITARY_MARGIN` below the defender. The LLM still proposes
// the transfer; the engine decides whether it holds (see `applyTurnResult`).
// Re-claims of unowned / no-man's-land regions are never gated.
/**
* (v0.30, #50) The margin of military superiority an attacker must NOT be more
* than below the defender for a contested conquest to succeed. A gap of exactly
* this many points is still enough to win; a gap one larger is repelled.
*/
export const CONQUEST_MILITARY_MARGIN = 15;
/**
* (v0.30, #50) Hard military gate for a **contested** conquest (a region
* currently owned by a different nation). Returns `true` — the invasion
* proceeds — unless the attacker's military is MORE than `margin` below the
* defender's: equal, or within `margin` (default {@link CONQUEST_MILITARY_MARGIN}),
* is enough to win, while a gap larger than `margin` is repelled.
*
* The comparison is intentionally one-sided (it only protects the *defender*
* from an overwhelming odds gap, not the attacker from a small disadvantage):
* `attacker < defender - margin` → repel; everything else (equal, within the
* margin, or ahead) → proceed. Pure + shared so the engine, the tests, and the
* prompt's frontier-balance hint all agree on the outcome.
*/
export function canConquer(
attackerMil: number,
defenderMil: number,
margin: number = CONQUEST_MILITARY_MARGIN,
): boolean {
return !(attackerMil < defenderMil - margin);
}
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
//
// An unowned region has two distinct looks, and the difference is NOT in the
// RegionMap (a key absent there is simply unowned): it is decided by the
// client from `GameState.unowned` (the keys released mid-game). The constants
// and the one decision function live here — shared and pure — so the
// client's choropleth is the only consumer and the color contract is testable
// from the server-side tests (shared/*.test.ts is not in the vitest glob).
/** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */
export const NO_MANS_LAND_FILL = '#3f3f46';
/** The border of a no-man's-land region (brighter than a plain unowned one). */
export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)';
/** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */
export const UNOWNED_FILL = 'rgba(148,163,184,0.05)';
/** The border of a plain unowned region. */
export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
/** The border of an owned region (brighter than unowned). */
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
/** The hover-tooltip sub-line for a region with no owning nation. */
export function unownedTooltipLabel(isNoMansLand: boolean): string {
return isNoMansLand ? "No man's land" : 'No owner';
}
/**
* The fill + border for one sub-region, given its RegionMap entry
* (`undefined` = unowned) and whether its key is no man's land (released
* mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section
* comment above for why the distinction is not encoded in the RegionMap.
*/
export function regionFillStyle(
owner: RegionOwner | undefined,
isNoMansLand: boolean,
): { fill: string; stroke: string } {
if (owner) return { fill: owner.color, stroke: OWNED_STROKE };
return isNoMansLand
? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE }
: { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE };
}
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
export function makeEvent(
spec: TurnEventSpec,
@@ -240,3 +581,137 @@ export function nationForPrompt(n: Nation) {
relations: n.relations,
};
}
// --- Per-region stats (v0.28, #46) ------------------------------------------
//
// A nation's 0–100 `NationStats` are no longer stored as independent per-nation
// values: they are DERIVED as the area×population-weighted median of the stats
// of the regions the nation owns (`GameState.regionStats`, keyed by region key).
// These pure helpers implement that derivation, the fresh-game / legacy seeding,
// and the one-time migration of a pre-v0.28 row. They have no runtime deps.
/**
* The weighted median of `values` with per-value `weights` (paired by index).
* The pairs are sorted by value ascending and the result is the value at the
* first point where the cumulative weight reaches at least half the total
* weight. An empty input, or a total weight of zero, yields 0. Non-finite or
* non-positive weights are treated as 0 (excluded from the median).
* Translation-invariant: adding a constant to every value shifts the result by
* that constant — so the median of a set of **identical** values is that value
* (the invariant the equal-value seeding and legacy migration rely on).
* Pure + shared.
*/
export function weightedMedian(values: number[], weights: number[]): number {
if (values.length === 0) return 0;
const pairs = values
.map((v, i) => {
const w =
typeof weights[i] === 'number' && Number.isFinite(weights[i]) && weights[i] > 0
? weights[i]
: 0;
return { v: Number.isFinite(v) ? v : 0, w };
})
.filter((p) => p.w > 0);
if (pairs.length === 0) return 0;
const total = pairs.reduce((s, p) => s + p.w, 0);
if (total <= 0) return 0;
pairs.sort((a, b) => a.v - b.v);
let cum = 0;
for (const p of pairs) {
cum += p.w;
if (cum >= total / 2) return p.v;
}
return pairs[pairs.length - 1]!.v;
}
/**
* Derive a nation's aggregate {@link NationStats} from the stats of the regions
* it owns: each of the four stats is the **area×population-weighted median**
* (see {@link weightedMedian}) over the nation's owned region keys. A region's
* weight is `(area > 0 ? area : 1) × (population > 0 ? population : 1)` — both
* factors fall back to 1 when the region has no geo entry (`geoOf` returns
* `undefined` for it), so every region carries weight ≥ 1 and a missing geo
* degrades gracefully to a uniform (unweighted) median. A region key with no
* `regionStats` entry is skipped. Returns the zero stats when the nation owns
* no region with a stats entry (the caller's 0-region guard keeps the nation's
* directly-maintained `n.stats` in that case). Pure + shared.
*/
export function deriveNationStats(
regionKeys: string[],
regionStats: Record<string, RegionStats>,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): NationStats {
const zero: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
const byStat: Record<StatKey, number[]> = {
military: [],
economy: [],
stability: [],
population: [],
};
const weights: number[] = [];
for (const key of regionKeys) {
const rs = regionStats[key];
if (!rs) continue; // a key with no per-region stats is skipped
const g = geoOf?.(key);
const a = g && g.area > 0 ? g.area : 1;
const p = g && g.population > 0 ? g.population : 1;
weights.push(a * p);
for (const k of STAT_KEYS) byStat[k].push(rs[k]);
}
if (weights.length === 0) return zero;
const out: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
for (const k of STAT_KEYS) out[k] = clampStat(weightedMedian(byStat[k], weights));
return out;
}
/**
* Seed a per-region stats table from a set of nations (used both at game
* creation and by the legacy migration): every owned region key gets a **copy**
* of its owner's current `NationStats`, so a fresh nation's regions all start
* identical (the weighted median of identical values is that value, so the
* derived `n.stats` equals the seeded one). A region with two or more owners
* (corrupt) is seeded by the first owner encountered; a key owned by nobody gets
* no entry. Pure + shared.
*/
export function seedRegionStats(nations: Record<string, Nation>): Record<string, RegionStats> {
const out: Record<string, RegionStats> = {};
for (const n of Object.values(nations)) {
for (const key of n.territory) {
if (!(key in out)) out[key] = { ...n.stats }; // first owner wins (sole ownership holds normally)
}
}
return out;
}
/**
* (v0.28, #46) One-time migration of a **legacy** (pre-v0.28) `GameState` row
* into the per-region-stats model, returning a new state (the input is never
* mutated):
* - a row that already has `regionStats` (a v0.28+ save) is returned
* **UNCHANGED** — its `Nation.stats` are already the correct derived medians
* and must not be recomputed on load (that would drift them);
* - a legacy row (`regionStats` absent at runtime) seeds every owned region key
* with a copy of its owner's current stats (so each nation's regions start
* identical), then recomputes each nation's stats as the weighted median of
* its regions (a 0-region nation keeps its stats). Without `geoOf` the
* weighting is uniform, which is weight-invariant for the identical-value
* case, so the recomputed stats equal the originals — the migration is a
* no-op on the visible numbers and only materializes the (previously
* implicit) per-region values. Never throws.
*/
export function migrateRegionStats(
state: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): GameState {
// A v0.28+ row already carries per-region stats: leave it exactly as saved.
if (state.regionStats !== undefined) return state;
// Legacy row: materialize per-region stats from each nation's (uniform) stats.
const next: GameState = { ...state, regionStats: seedRegionStats(state.nations) };
for (const n of Object.values(next.nations)) {
if (n.territory.length === 0) continue; // 0-region guard: keep its stats
if (n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
return next;
}
+132 -1
View File
@@ -1,5 +1,9 @@
// Domain types — the single source of truth shared by the frontend (src/), the
// 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. */
export interface NationStats {
@@ -9,6 +13,19 @@ export interface NationStats {
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;
}
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
export type StatKey = (typeof STAT_KEYS)[number];
@@ -62,7 +79,69 @@ export const EVENT_TYPES: EventType[] = [
'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;
}
/** A narrative event the simulation emitted on some turn. */
export interface EventEntry {
@@ -120,6 +199,58 @@ export interface GameState {
* in src/App.tsx. Starts at 1 on game creation.
*/
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>;
}
/**
+58
View File
@@ -177,6 +177,10 @@ export default function App() {
// 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);
// The id of the game the user is/was last in (mirrors localStorage) and the
// list of saved games (newest first) shown on the home screen.
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
@@ -401,6 +405,44 @@ export default function App() {
[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
// sets `busy` (the adds above are fast and non-LLM).
const runJump = useCallback(async () => {
@@ -507,12 +549,15 @@ export default function App() {
regions={regions}
busy={busy}
advisorBusy={advisorBusy}
opBusy={opBusy}
advisors={advisors}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
onRemoveQueueItem={runRemoveQueueItem}
onAdvisor={askAdvisor}
onJump={runJump}
onConfirmNationOp={runConfirmNationOp}
onRejectNationOp={runRejectNationOp}
onHome={goHome}
/>
</>
@@ -551,12 +596,16 @@ interface GameScreenProps {
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[];
onAddAction: (a: string) => void;
onAddDiplomacy: (to: string, m: string) => void;
onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void;
onJump: () => void;
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
onRejectNationOp: (index: number) => void;
onHome: () => void;
}
@@ -579,12 +628,15 @@ function GameScreen({
regions,
busy,
advisorBusy,
opBusy,
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
onConfirmNationOp,
onRejectNationOp,
onHome,
}: GameScreenProps) {
// Which nation's dossier to show. Defaults to the player; remounts per game.
@@ -653,6 +705,9 @@ function GameScreen({
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
unowned={game.unowned ?? []}
markers={game.markers ?? []}
regionStats={game.regionStats ?? {}}
onFocusNation={focusNation}
/>
@@ -834,12 +889,15 @@ function GameScreen({
game={game}
busy={busy}
advisorBusy={advisorBusy}
opBusy={opBusy}
advisors={advisors}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}
onRemoveQueueItem={onRemoveQueueItem}
onAdvisor={onAdvisor}
onJump={onJump}
onConfirmNationOp={onConfirmNationOp}
onRejectNationOp={onRejectNationOp}
inWindow
/>
</FloatingWindow>
+46 -1
View File
@@ -1,5 +1,5 @@
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 { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
@@ -15,6 +15,7 @@ import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import NationOpDialog from './NationOpDialog';
import type { GameState, RoundItem } from '@shared/types';
interface Props {
@@ -27,6 +28,11 @@ interface Props {
* 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. */
advisors: string[];
/** Queue a free-text action onto the pending round (no turn advance). */
@@ -38,6 +44,14 @@ interface Props {
onAdvisor: (prompt?: string) => void;
/** Commit the whole pending round in a single LLM turn. */
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
* provides the border and the "Your move" title). Same content, same testids.
@@ -66,12 +80,15 @@ export default function ActionPanel({
game,
busy,
advisorBusy,
opBusy,
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
onConfirmNationOp,
onRejectNationOp,
inWindow = false,
}: Props) {
const [tab, setTab] = useState('action');
@@ -81,6 +98,8 @@ export default function ActionPanel({
const [prompt, setPrompt] = useState('');
// Index of the queued item the user is about to remove, or null (dialog closed).
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
// Whether the nation-op review dialog is open.
const [opDialogOpen, setOpDialogOpen] = useState(false);
const others = Object.entries(game.nations)
.filter(([id]) => id !== game.playerNationId)
@@ -108,6 +127,7 @@ export default function ActionPanel({
};
const pending = game.pending;
const ops = game.pendingNationOps;
// The item the confirmation dialog is about (re-derived from the live list,
// so if the queue changes while the dialog is open it tracks the current
// item at that index — or the dialog closes if the index went out of range).
@@ -325,6 +345,31 @@ export default function ActionPanel({
</DialogContent>
</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">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
{queuedLabel}
+307
View File
@@ -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>
);
}
+434 -23
View File
@@ -8,8 +8,16 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl);
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import {
mapFeatureOwner,
MARKER_TYPE_INFO,
nationColor,
regionFillStyle,
unownedTooltipLabel,
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
import { MARKER_TYPES, type MapMarker, type Nation, type RegionStats } from '@shared/types';
import { cn } from '@/lib/utils';
import { idToKey } from '@/lib/regions';
@@ -39,8 +47,16 @@ type GeoSource = maplibregl.GeoJSONSource;
// looked black, and `styledata` was gated on fetching a remote document. An
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
// raster has no vector tiles, so no glyphs endpoint is needed — we don't add
// label layers; the choropleth provides the borders.)
// raster has no vector tiles, so the basemap itself needs no label layers —
// the choropleth provides the borders.)
//
// No `glyphs` URL: the map needs no MapLibre *text* rendering. The basemap is
// raster (its labels are baked into the PNG tiles), and the LLM-placed map
// markers (v0.27, #44) are drawn as canvas-generated icon images in a symbol
// layer — fully self-contained, so they render offline and deterministically
// without depending on any external font/glyph endpoint (the OpenFreeMap
// `fonts/` path is a dead nginx 404, and the basemap's apparent labels come
// from the raster, not from glyphs).
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
const INLINE_STYLE: maplibregl.StyleSpecification = {
@@ -82,6 +98,8 @@ const PLAYER_LINE = 'player-line';
const FLASH_LINE = 'flash-line';
const HOVER_FILL = 'hover-fill';
const HOVER_LINE = 'hover-line';
const MARKERS_SOURCE = 'markers';
const MARKER_SYMBOL = 'markers-symbol';
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
@@ -114,19 +132,27 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
* - `stroke` – a per-border alpha (owned brighter than unowned);
* - `stroke` – a per-border alpha (owned brightest, no man's land next,
* plain-unowned faintest);
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
* player-highlight border).
*
* Crucially, the owner lookup keys the RegionMap by the stamped
* `properties.regionKey`, **never** by the sub-region id or the repacked
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
*
* (v0.22, #16 P3) Unowned regions have two looks, decided here (the
* RegionMap simply has no entry for them): a key in `unownedSet` — released to
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
*/
function applyRegions(
geo: FeatureCollection,
regions: RegionMap,
playerNationId: string,
idKey: Record<string, string>,
unownedSet: ReadonlySet<string>,
): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
@@ -141,14 +167,18 @@ function applyRegions(
f.properties.regionKey = key;
const r = key ? regions[key] : undefined;
if (r) {
f.properties.fill = r.color;
const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId;
f.properties.stroke = 'rgba(255,255,255,0.30)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
f.properties.fill = 'rgba(148,163,184,0.05)';
f.properties.stroke = 'rgba(255,255,255,0.14)';
// Ownerless: visible grey only for keys released mid-game (no man's
// land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0;
@@ -177,6 +207,186 @@ function featuresByRegionKeys(
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
}
// --- Map markers (v0.27, #44) ------------------------------------------------
//
// The LLM places named point markers (cities, factories, armies, ports,
// capitals, fleets, rocket sites, gatherings) anchored to a region key. The
// engine positions each at its region's centroid (deterministic small offset)
// and recomputes its owner from the region's current owner every turn. The
// map renders them as a single top-most symbol layer: a per-type glyph
// character in front of the name, per-type color, dark-theme halo.
/**
* The GeoJSON behind the marker symbol layer: one Point per marker at
* `[lon, lat]`, carrying the stable id, type, name, its type's glyph, and the
* current owner's name + stable color (empty when the region is unowned).
* Pure; the caller passes the current marker list + nations record, so a
* renamed/removed owner is picked up on the next paint.
*/
function buildMarkersFC(markers: MapMarker[], nations: Record<string, Nation>): FeatureCollection {
const features = markers.map((m) => {
const owner = m.ownerNationId ? nations[m.ownerNationId] : undefined;
return {
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] as [number, number] },
properties: {
id: m.id,
type: m.type,
name: m.name,
glyph: MARKER_TYPE_INFO[m.type]?.glyph ?? '',
owner: owner?.name ?? '',
ownerId: owner?.id ?? '',
ownerColor: owner ? nationColor(owner.id) : '',
},
};
});
return { type: 'FeatureCollection', features };
}
// Small canvas-path helpers for the marker icons (self-contained shapes — no
// unicode/font dependency, so they render identically in any environment).
function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function starPath(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
spikes: number,
outer: number,
inner: number,
): void {
let rot = (Math.PI / 2) * -1;
const step = Math.PI / spikes;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
for (let i = 0; i < spikes; i++) {
rot += step;
ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner);
rot += step;
ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
}
ctx.closePath();
}
/**
* (v0.27, #44) Draw one marker-type icon into an offscreen canvas: a distinct
* shape per type, filled with the type's color from the shared
* `MARKER_TYPE_INFO` contract, with a dark outline for legibility on any
* choropleth fill. Added to the map as a named image and referenced by the
* symbol layer's `icon-image` — a real symbol layer, per-type color + form,
* with zero external font/glyph dependency.
*/
function drawMarkerIcon(type: MapMarker['type']): ImageData {
const S = 44;
const c = document.createElement('canvas');
c.width = S;
c.height = S;
const ctx = c.getContext('2d')!;
const cx = S / 2;
const cy = S / 2;
ctx.fillStyle = MARKER_TYPE_INFO[type]?.color ?? '#cbd5e1';
ctx.strokeStyle = '#0b1220';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
switch (type) {
case 'city':
ctx.beginPath();
ctx.arc(cx, cy, S * 0.3, 0, Math.PI * 2);
break;
case 'factory': {
const h = S * 0.5;
roundRectPath(ctx, cx - h / 2, cy - h / 2, h, h, 5);
break;
}
case 'army': {
const r = S * 0.34;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy + r * 0.8);
ctx.lineTo(cx - r, cy + r * 0.8);
ctx.closePath();
break;
}
case 'port': {
const r = S * 0.36;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
break;
}
case 'capital':
starPath(ctx, cx, cy, 5, S * 0.36, S * 0.15);
break;
case 'fleet':
case 'gathering':
ctx.beginPath();
ctx.arc(cx, cy, S * 0.32, 0, Math.PI * 2);
break;
case 'rocket': {
const w = S * 0.14;
ctx.beginPath();
ctx.moveTo(cx, cy - S * 0.36);
ctx.lineTo(cx + S * 0.22, cy - S * 0.04);
ctx.lineTo(cx + w, cy - S * 0.04);
ctx.lineTo(cx + w, cy + S * 0.34);
ctx.lineTo(cx - w, cy + S * 0.34);
ctx.lineTo(cx - w, cy - S * 0.04);
ctx.lineTo(cx - S * 0.22, cy - S * 0.04);
ctx.closePath();
break;
}
}
ctx.fill();
ctx.stroke();
// fleet and gathering share a circle base; an inner mark tells them apart
// (fleet = a dark porthole dot, gathering = a white bullseye dot).
if (type === 'fleet' || type === 'gathering') {
ctx.beginPath();
ctx.arc(cx, cy, S * 0.13, 0, Math.PI * 2);
ctx.fillStyle = type === 'fleet' ? '#0b1220' : '#ffffff';
ctx.fill();
}
// Hand MapLibre the raw pixels (an untainted canvas, so getImageData is safe)
// rather than the canvas element — `addImage` accepts ImageData directly.
return ctx.getImageData(0, 0, S, S);
}
/** The hover tooltip for a marker: its type label + name, then the owner chip. */
function markerTooltipHtml(
type: string,
name: string,
owner: string,
ownerColor: string,
): string {
const label = MARKER_TYPE_INFO[type as MapMarker['type']]?.label ?? 'Marker';
const ownerLine = owner
? `<div class="hm-popup-owner"><span class="hm-chip" style="background:${ownerColor}"></span>${esc(owner)}</div>`
: '<div class="hm-popup-sub">No owner</div>';
return `<div class="hm-popup"><div class="hm-popup-title">${esc(label)}: ${esc(name)}</div>${ownerLine}</div>`;
}
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
@@ -188,16 +398,41 @@ function statRow(label: string, value?: number): string {
/**
* Compact hover tooltip: the sub-region's name, its **current owner**
* (color-chipped), and 1–2 key stats. When the region's country has no owning
* nation, a minimal "No owner" card is shown instead. The static
* parent-country code (`properties.parent`) is deliberately NOT printed here —
* it never updates when ownership is transferred, so only the live owner chip
* (resolved from the RegionMap) is shown.
* nation, a minimal card is shown instead; its sub-line says "No man's land"
* for a region released mid-game (v0.22, #16 P3) and "No owner" for a region
* no preset ever assigned. The static parent-country code
* (`properties.parent`) is deliberately NOT printed here — it never updates
* when ownership is transferred, so only the live owner chip (resolved from
* the RegionMap) is shown.
*/
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
function hoverHtml(
subRegionName: string,
region?: RegionOwner,
nation?: Nation,
noMansLand = false,
regionKey?: string,
regionStats?: Record<string, RegionStats>,
): string {
if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
}
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
// derived aggregate), looked up by region key. When the region has no stats
// entry (defensive — an owned region should always have one) every stat
// shows a placeholder dash. The dossier (NationPanel) still shows the
// nation's derived stats, so this tooltip is the one place a single region's
// numbers are visible on the map.
const rs = regionKey ? regionStats?.[regionKey] : undefined;
const row = (label: string, v?: number): string =>
rs == null
? `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">–</span></div>`
: statRow(label, v);
const stats = [
row('Mil', rs?.military),
row('Eco', rs?.economy),
row('Stab', rs?.stability),
row('Pop', rs?.population),
].join('');
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
@@ -213,6 +448,14 @@ interface Props {
/** The active scenario id, for a sensible initial camera. */
presetId: string;
className?: string;
/**
* (v0.22, #16 P3) Region keys currently in no man's land (released
* mid-game — `GameState.unowned`). Such regions render the distinct visible
* grey (vs the faint preset-unassigned look of every other ownerless
* region) and their hover tooltip says "No man's land" instead of "No
* owner".
*/
unowned?: string[];
/**
* Called with a region's owning-nation id when a sub-region is clicked, so the
* dossier switches to that nation. The optional `info` carries the clicked
@@ -221,6 +464,21 @@ interface Props {
* component doc for the full behavior.
*/
onFocusNation?: (id: string, info?: { name?: string }) => void;
/**
* (v0.27, #44) The game's named map markers (`GameState.markers`): the LLM
* places/removes them over time, the engine positions each at its region's
* centroid and keeps its owner in sync with the region's owner. Rendered as
* a top-most symbol layer (per-type glyph + name, per-type color).
*/
markers?: MapMarker[];
/**
* (v0.29, #46 PR 2) The game's per-region stats (`GameState.regionStats`,
* keyed by region key). Used only by the hover tooltip to show a single
* region's own four 0–100 stats (a region with no entry shows placeholders).
* The dossier (NationPanel) is unaffected — it keeps the nation's derived
* stats.
*/
regionStats?: Record<string, RegionStats>;
}
/**
@@ -246,6 +504,25 @@ interface Props {
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
* (no RegionMap entry for their key) simply do not change the dossier.
*
* (v0.22, #16 P3) An unowned region renders two distinct ways: a key in the
* `unowned` prop (released to no man's land mid-game — `GameState.unowned`)
* gets the distinct visible grey with a "No man's land" tooltip sub-line; every
* other ownerless region (a preset never assigned it) keeps the faint look and
* a plain "No owner" sub-line. The colors/labels come from the shared
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
*
* (v0.27, #44) LLM-placed **map markers** (`markers` prop = `GameState.markers`)
* render on a top-most symbol layer: each is its type's canvas-generated icon
* (a distinct shape, per-type color from the shared `MARKER_TYPE_INFO`
* contract, dark-outlined) at the engine-derived position (region centroid +
* small deterministic offset). The icons are drawn into named map images
* (`marker-<type>`), so there is no external font/glyph dependency and markers
* render offline and deterministically. Hovering a marker shows a tooltip
* (type label + name + owner chip); clicking one switches the dossier to the
* owning nation (no-op when the marker's region is unowned). The region
* hover/click handlers detect a marker under the cursor (MapLibre layer events
* have no propagation) and act on the marker's data.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API.
*/
@@ -255,7 +532,10 @@ export default function WorldMap({
nations,
presetId,
className,
unowned,
onFocusNation,
markers,
regionStats,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -268,12 +548,19 @@ export default function WorldMap({
const nationsRef = useRef(nations);
const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId);
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
const onFocusNationRef = useRef(onFocusNation);
const markersRef = useRef<MapMarker[]>(markers ?? []);
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
presetRef.current = presetId;
unownedRef.current = new Set(unowned ?? []);
onFocusNationRef.current = onFocusNation;
markersRef.current = markers ?? [];
regionStatsRef.current = regionStats ?? {};
// Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes).
@@ -381,6 +668,39 @@ export default function WorldMap({
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
// (v0.27, #44) A marker rendered on top of this region wins the hover.
// The region polygon is always beneath a marker, so this FILL_LAYER
// delegate fires in both cases; if a marker icon is under the cursor,
// show the marker's tooltip (name + type + owner) instead of the
// region's. `e.point` is the cursor's pixel position, so querying the
// marker layer there tests the icon's (small) hit area, not the whole
// region.
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
if (markerHits.length > 0) {
const mp = (markerHits[0].properties ?? {}) as {
type?: string;
name?: string;
owner?: string;
ownerColor?: string;
};
hoverPopup
.setLngLat(e.lngLat)
.setHTML(
markerTooltipHtml(
typeof mp.type === 'string' ? mp.type : '',
typeof mp.name === 'string' ? mp.name : '',
typeof mp.owner === 'string' ? mp.owner : '',
typeof mp.ownerColor === 'string' ? mp.ownerColor : '',
),
)
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
// A marker has no region outline to highlight; clear the previous one
// (and reset lastHoverId so moving back onto a region re-triggers it).
lastHoverId = null;
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC());
return;
}
const p = f.properties as { name?: string; regionId?: string };
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
if (subId && subId !== lastHoverId) {
@@ -396,9 +716,14 @@ export default function WorldMap({
}
const region = mapFeatureOwner(regionsRef.current, f);
const nation = region ? nationsRef.current[region.ownerId] : undefined;
// (v0.22, #16 P3) whether this region is no man's land (released
// mid-game), so the ownerless tooltip can say so.
const pKey = f.properties?.regionKey;
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation))
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -418,6 +743,19 @@ export default function WorldMap({
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
// (v0.27, #44) A marker under the cursor: focus its owning nation
// (identical to its region's owner — the engine keeps a marker's owner in
// sync with the region) and carry the marker's name into the dossier.
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
if (markerHits.length > 0) {
const mp = (markerHits[0].properties ?? {}) as { ownerId?: string; name?: string };
const owner = typeof mp.ownerId === 'string' ? mp.ownerId : '';
if (owner) {
const info = typeof mp.name === 'string' ? { name: mp.name } : undefined;
onFocusNationRef.current?.(owner, info);
}
return;
}
const region = mapFeatureOwner(regionsRef.current, f);
if (!region?.ownerId) return;
// Carry the clicked sub-region's name so the dossier can show which region
@@ -449,6 +787,9 @@ export default function WorldMap({
if (!map.getSource(FLASH_SOURCE)) {
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
}
if (!map.getSource(MARKERS_SOURCE)) {
map.addSource(MARKERS_SOURCE, { type: 'geojson', data: emptyFC() });
}
};
// Stamp ownership + colors onto the regions source (no-op until both the
@@ -458,7 +799,27 @@ export default function WorldMap({
const idKey = idKeyRef.current;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !idKey || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey));
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
};
// Stamp the current markers onto the marker source (v0.27, #44).
const paintMarkers = () => {
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
if (!src) return;
src.setData(buildMarkersFC(markersRef.current, nationsRef.current));
};
// Register the eight canvas-generated marker-type icon images (v0.27, #44).
// Images are cleared on a style (re)load, so this is (re)run from
// `ensureLayers` (which fires on every `styledata`). Self-contained — no
// external font/glyph endpoint — so markers render offline and
// deterministically.
const ensureMarkerImages = async () => {
for (const type of MARKER_TYPES) {
const id = `marker-${type}`;
if (map.getImage(id)) continue;
await map.addImage(id, drawMarkerIcon(type));
}
};
const ensureLayers = async () => {
@@ -480,6 +841,7 @@ export default function WorldMap({
}
if (cancelled) return;
paintRegions();
paintMarkers();
if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain.
@@ -506,8 +868,9 @@ export default function WorldMap({
}
// NOTE: layers are added in bottom→top order (each appends on top). The
// stack is: fill, base borders, player border, territory-change flash,
// hover brighten, hover outline. Do not use a `beforeId` here — it throws
// if that layer does not exist yet on a fresh style load.
// hover brighten, hover outline, and (top-most) the LLM-placed map
// markers' symbol layer (v0.27, #44). Do not use a `beforeId` here — it
// throws if that layer does not exist yet on a fresh style load.
if (!map.getLayer(PLAYER_LINE)) {
map.addLayer({
id: PLAYER_LINE,
@@ -541,6 +904,36 @@ export default function WorldMap({
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
});
}
// (v0.27, #44) The LLM-placed map markers: a top-most symbol layer.
// Each marker renders its type's canvas-generated icon (a distinct shape,
// per-type color from the shared MARKER_TYPE_INFO contract) with a dark
// outline for legibility on any choropleth fill. The icon images are
// self-contained (no external font/glyph endpoint), so markers render
// offline and deterministically. `icon-image` is `marker-<type>`, the
// named canvas images registered by `ensureMarkerImages` above.
await ensureMarkerImages();
if (!map.getLayer(MARKER_SYMBOL)) {
map.addLayer({
id: MARKER_SYMBOL,
type: 'symbol',
source: MARKERS_SOURCE,
layout: {
'icon-image': ['concat', 'marker-', ['get', 'type']],
'icon-size': 0.8,
'icon-allow-overlap': true,
'icon-ignore-placement': true,
},
});
}
// Marker interactions: the markers sit above the choropleth, but they
// deliberately share the region (FILL_LAYER) handlers registered below.
// MapLibre layer-scoped events are independent per-layer delegates (no
// propagation, no stopPropagation), so a marker-on-region would fire
// both a marker and a region handler. Instead the region handlers detect
// a marker under the cursor (see onHover/onClick) and act on the
// marker's data; clicking a marker focuses its owning nation via the
// same path as clicking its region (the marker's owner == its region's
// owner).
map.off('mousemove', FILL_LAYER, onHover);
map.off('mouseout', FILL_LAYER, onHoverOut);
map.off('click', FILL_LAYER, onClick);
@@ -568,7 +961,9 @@ export default function WorldMap({
// sub-region whose owner changed since the last update (a transient,
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed
// key.
// key. Re-runs when the no-man's-land set changes too (v0.22 #16 P3) so a
// release/re-claim repaints the grey even if the RegionMap diff alone
// wouldn't catch it.
useEffect(() => {
const map = mapRef.current;
const geo = baseGeoRef.current;
@@ -578,10 +973,11 @@ export default function WorldMap({
prevRegionsRef.current = cur;
const wasPrimed = primedRef.current;
if (Object.keys(cur).length > 0) primedRef.current = true;
const unownedSet = new Set(unowned ?? []);
if (map && geo && idKey) {
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey));
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
}
if (wasPrimed && prev && idKey) {
@@ -589,9 +985,24 @@ export default function WorldMap({
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
}
// A release (owned → unowned) and a re-claim (unowned → owned) both show
// up as ownerId changes above, so they flash like any other transfer.
if (changed.size > 0) flashApiRef.current?.([...changed]);
}
}, [regions, playerNationId]);
}, [regions, playerNationId, unowned]);
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
// changes (a turn placed/removed markers) or the nations record changes
// (a confirmed rename/merge changes an owner's display name). No-op until
// the map + source exist (the mount effect's ensureLayers applies the
// current data once the layer structure is in place).
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
if (!src) return;
src.setData(buildMarkersFC(markers ?? [], nations));
}, [markers, nations]);
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
+20
View File
@@ -6,9 +6,11 @@ import type {
AddActionResponse,
AddDiplomacyResponse,
AdvisorResponse,
ConfirmNationOpResponse,
CreateGameResponse,
DeleteGameResponse,
GameSummary,
RejectNationOpResponse,
RemoveQueueItemResponse,
TurnResponse,
} from '@shared/protocol';
@@ -60,6 +62,24 @@ export const api = {
method: 'POST',
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.
jump: (id: string) =>
req<TurnResponse>(`/api/games/${id}/jump`, {
+3
View File
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
failure: '#ef4444',
partial: '#f59e0b',
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',
};
/**