Compare commits

...
17 Commits
Author SHA1 Message Date
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
28 changed files with 2869 additions and 101 deletions
+99 -16
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,25 +56,30 @@ 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; 168 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)
├── 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
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both 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
├── 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) / 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
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
@@ -91,7 +100,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,10 +115,12 @@ 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/`; 168 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 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` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
@@ -171,7 +183,72 @@ 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 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 +271,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**
+59
View File
@@ -2,6 +2,65 @@
All notable changes to **History** are listed here, newest first.
## 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.
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.21.0",
"version": "0.26.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -14,6 +14,8 @@
"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",
"typecheck": "tsc --noEmit",
+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",
+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
+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(', ')}`);
+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',
]);
});
});
+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}`);
+24
View File
@@ -23,6 +23,8 @@ function makeState(id: string): GameState {
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
};
}
@@ -73,6 +75,28 @@ 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('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+8 -1
View File
@@ -39,7 +39,14 @@ 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;
const 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 = [];
return state;
} catch {
return null;
}
+798 -8
View File
@@ -1,20 +1,27 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types';
import { makeEvent, type TurnResult } from '../shared/protocol';
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub';
import {
applyNationOp,
applyTurnResult,
buildSystemPrompt,
buildUserPrompt,
confirmNationOp,
freshNationId,
GET_NEIGHBOURS_TOOL,
isMeaningful,
makeNeighboursExecutor,
NEW_NATION_STATS,
parseTurnResult,
playerFrontier,
queueRoundItem,
rejectNationOp,
removeRoundItem,
resolveNationRef,
runTurn,
validateNationOps,
validateTurnResult,
} from './engine';
@@ -78,6 +85,8 @@ function baseState(): GameState {
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
};
}
@@ -92,8 +101,8 @@ const validResult = JSON.stringify({
],
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
// v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
// owner, so the transfer applies (an unowned key like 'POL' would be ignored
// by the new sole-ownership rules in applyTurnResult).
// owner, so the transfer applies (since v0.22 #16 P3 a key owned by NOBODY
// would be re-claimed instead of ignored; a release lives in "unowned").
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
});
@@ -338,6 +347,22 @@ describe('buildUserPrompt (batched round)', () => {
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
expect(withoutRef.regions).toBeUndefined();
});
it('(v0.22 #16 P3) emits the no-man\'s-land list when provided (even when empty) and omits it otherwise', () => {
// runTurn always passes state.unowned (even []), so the field is present...
const withEmpty = JSON.parse(buildUserPrompt(baseState(), [], undefined, undefined, [])) as {
unowned?: string[];
};
expect(withEmpty.unowned).toEqual([]);
// ...and a populated list reaches the model verbatim (it knows what it can re-claim)
const withKeys = JSON.parse(
buildUserPrompt(baseState(), [], undefined, undefined, ['GBR', 'POL']),
) as { unowned?: string[] };
expect(withKeys.unowned).toEqual(['GBR', 'POL']);
// but a caller that does not provide it keeps the legacy payload shape
const omitted = JSON.parse(buildUserPrompt(baseState(), [])) as { unowned?: string[] };
expect(omitted.unowned).toBeUndefined();
});
});
describe('queueRoundItem', () => {
@@ -408,6 +433,559 @@ describe('removeRoundItem', () => {
});
});
describe('resolveNationRef (v0.23 #16 P4a)', () => {
it('resolves an exact id first (even if a name matches something else)', () => {
const state = baseState();
expect(resolveNationRef(state, 'de')).toBe('de');
});
it('resolves a unique name case-insensitively', () => {
const state = baseState();
expect(resolveNationRef(state, 'US')).toBe('us');
expect(resolveNationRef(state, ' uS ')).toBe('us'); // trimmed, case-insensitive
});
it('returns null for an ambiguous name (two nations share it)', () => {
const state = baseState();
state.nations.us.name = 'UK'; // now uk and us are both "UK"
expect(resolveNationRef(state, 'UK')).toBeNull();
// but the exact id still resolves
expect(resolveNationRef(state, 'uk')).toBe('uk');
});
it('returns null for an unknown or empty reference (never throws)', () => {
const state = baseState();
expect(resolveNationRef(state, 'zzz-not-a-nation')).toBeNull();
expect(resolveNationRef(state, ' ')).toBeNull();
expect(resolveNationRef(state, '')).toBeNull();
});
});
describe('validateNationOps (v0.23 #16 P4a)', () => {
it('parses a valid rename (trimmed, capped) and drops invalid ones', () => {
const ops = validateNationOps([
{ kind: 'rename', nationRef: ' de ', to: 'Reich' },
{ kind: 'rename', nationRef: 'uk', to: '' }, // empty to -> dropped
{ kind: 'rename', to: 'no ref' }, // missing nationRef -> dropped
{ kind: 'rename', nationRef: 'us' }, // missing to -> dropped
{ kind: 'bogus-kind' }, // unknown kind -> ignored
'not-an-object',
null,
]);
expect(ops).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
it('parses a valid split (parts with names kept, nameless parts dropped)', () => {
const ops = validateNationOps([
{
kind: 'split',
nationRef: 'de',
parts: [
{ name: 'East', territory: ['DEU', ''] },
{ name: ' ' }, // empty name -> part dropped
{ name: 'West' },
],
},
]);
expect(ops).toEqual([
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East', territory: ['DEU'] }, { name: 'West', territory: [] }],
},
]);
// a split with no usable parts is dropped entirely
expect(validateNationOps([{ kind: 'split', nationRef: 'de', parts: [{ name: '' }] }])).toEqual([]);
});
it('parses a valid merge and drops malformed ones', () => {
const ops = validateNationOps([
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
{ kind: 'merge', nationRefs: [], survivingRef: 'uk', into: 'X' }, // no refs -> dropped
{ kind: 'merge', nationRefs: ['uk'], survivingRef: '', into: 'X' }, // no survivor -> dropped
{ kind: 'merge', nationRefs: ['uk'], survivingRef: 'uk' }, // no into -> dropped
]);
expect(ops).toEqual([
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
]);
});
it('returns [] for non-array input and is never thrown by', () => {
expect(validateNationOps(undefined)).toEqual([]);
expect(validateNationOps('nope')).toEqual([]);
expect(validateNationOps({})).toEqual([]);
});
it('validateTurnResult carries validated nationOps through (and defaults to [] when absent)', () => {
const withOps = validateTurnResult({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }],
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
expect(without).not.toBeNull();
expect(without!.nationOps).toEqual([]);
});
});
describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
it('a proposed rename is NOT applied by the turn — it lands in pendingNationOps, name untouched', () => {
const state = baseState();
const next = applyTurnResult(state, {
date: '1939-09-03',
events: [{ nation: 'de', type: 'political', title: 'Reunification', text: 'The nation is renamed.', outcome: 'success' }],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
expect(next.version).toBe(2);
// the input is not mutated
expect(state.nations.de.name).toBe('DE');
expect(state.pendingNationOps).toEqual([]);
});
it('pending proposals SURVIVE a subsequent turn (unlike the round queue) and new ones append', () => {
const state = baseState();
state.pendingNationOps = [{ kind: 'rename', nationRef: 'de', to: 'Reich' }]; // unresolved from turn N-1
const next = applyTurnResult(state, {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
{ kind: 'rename', nationRef: 'us', to: 'USA' }, // the new one was appended
]);
});
it('a legacy state without the pendingNationOps field accepts proposals (defaulted to [])', () => {
const state = baseState();
delete (state as { pendingNationOps?: NationOp[] }).pendingNationOps; // simulate a pre-v0.23 row
const next = applyTurnResult(state, {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
});
describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
/**
* The pending list: op 0 is referenced by id ('de'); op 1 is referenced by
* NAME ('US' — the id is lowercase 'us'), so confirming it exercises the
* name-resolution path of resolveNationRef.
*/
function stateWithPending(): GameState {
const s = baseState();
s.pendingNationOps = [
{ kind: 'rename', nationRef: 'de', to: 'Reich' },
{ kind: 'rename', nationRef: 'US', to: 'United States' },
];
return s;
}
it('confirm applies a rename (name only — id/territory/relations/player pointer untouched) and removes the op, bumping the version', () => {
const state = stateWithPending();
const next = confirmNationOp(state, 0);
expect(next).not.toBeNull();
expect(next!.nations.de.name).toBe('Reich');
expect(next!.nations.de.id).toBe('de'); // id unchanged
expect(next!.nations.de.territory).toEqual(['DEU']); // territory unchanged
expect(next!.nations.uk.relations.de).toBe('hostile'); // relations (keyed by id) unchanged
expect(next!.playerNationId).toBe('uk'); // player pointer unchanged
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
expect(next!.version).toBe(2);
// the input state is not mutated
expect(state.nations.de.name).toBe('DE');
expect(state.pendingNationOps).toHaveLength(2);
expect(state.version).toBe(1);
});
it('confirm with an edits.name override renames to the player-edited name', () => {
const next = confirmNationOp(stateWithPending(), 0, { name: 'Greater Reich' });
expect(next!.nations.de.name).toBe('Greater Reich');
});
it('confirm with an empty edits.name falls back to the proposed name', () => {
const next = confirmNationOp(stateWithPending(), 0, { name: ' ' });
expect(next!.nations.de.name).toBe('Reich');
});
it('confirm resolves the target by name (not just id) — case-insensitively', () => {
const next = confirmNationOp(stateWithPending(), 1); // nationRef 'US' is a name, not the id 'us'
expect(next).not.toBeNull();
expect(next!.nations.us.name).toBe('United States');
});
it('confirm of a 1-part split is a no-op on the world (fewer than 2 parts) — the op is still removed', () => {
const state = baseState();
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
const next = confirmNationOp(state, 0);
expect(next).not.toBeNull();
expect(next!.nations).toEqual(state.nations); // the world is untouched
expect(next!.pendingNationOps).toEqual([]); // ...but the proposal was resolved
expect(next!.version).toBe(2);
});
it('confirm with an invalid index returns null and mutates nothing', () => {
const state = stateWithPending();
expect(confirmNationOp(state, -1)).toBeNull();
expect(confirmNationOp(state, 2)).toBeNull();
expect(confirmNationOp(state, 0.5)).toBeNull();
expect(state.nations.de.name).toBe('DE');
expect(state.version).toBe(1);
});
it('reject drops the proposal without applying it, bumping the version', () => {
const state = stateWithPending();
const next = rejectNationOp(state, 0);
expect(next).not.toBeNull();
expect(next!.nations.de.name).toBe('DE'); // unchanged
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
expect(next!.version).toBe(2);
expect(state.pendingNationOps).toHaveLength(2); // input untouched
});
it('reject with an invalid index returns null and mutates nothing', () => {
const state = stateWithPending();
expect(rejectNationOp(state, -1)).toBeNull();
expect(rejectNationOp(state, 2)).toBeNull();
expect(state.pendingNationOps).toHaveLength(2);
expect(state.version).toBe(1);
});
it('an ambiguous/invalid nationRef is a no-op on confirm (the op is resolved, the world unchanged)', () => {
const state = baseState();
state.nations.us.name = 'UK'; // make "UK" ambiguous (uk and us)
state.pendingNationOps = [{ kind: 'rename', nationRef: 'UK', to: 'Empire' }];
const next = confirmNationOp(state, 0);
expect(next).not.toBeNull();
expect(next!.nations.uk.name).toBe('UK'); // neither nation was renamed
expect(next!.nations.us.name).toBe('UK');
expect(next!.pendingNationOps).toEqual([]);
});
});
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
it('both modes contract the nationOps field with all three op kinds and the proposal rule', () => {
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
// the JSON shape carries all three kinds in both modes
expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
// the rule explains that ops are proposals only the player can confirm
expect(p).toMatch(/PROPOSAL/);
expect(p).toMatch(/only take effect if the player confirms/);
// and the split/merge shapes are spelt out in the hard constraints
expect(p).toMatch(/at least 2 parts/);
expect(p).toMatch(/survivingRef" must name one of them/);
}
});
});
describe('freshNationId (v0.24 #16 P4b)', () => {
it('returns the first free of n1, n2, ... against the taken set', () => {
expect(freshNationId(new Set())).toBe('n1');
expect(freshNationId(new Set(['n1']))).toBe('n2');
expect(freshNationId(new Set(['n1', 'n2', 'n3']))).toBe('n4');
// non-n ids never collide: existing nation ids are irrelevant
expect(freshNationId(new Set(['uk', 'de', 'us']))).toBe('n1');
});
});
describe('applyNationOp (split, v0.24 #16 P4b)', () => {
/** de owns four regions; the player is uk (so a split of de does not move the pointer). */
function splitState(): GameState {
const s = baseState();
s.nations.de.territory = ['DEU-N', 'DEU-S', 'DEU-E', 'DEU-W'];
s.nations.de.relations = { uk: 'ally' };
s.nations.uk.relations.de = 'hostile'; // mirror entry, to check the cleanup
return s;
}
it('partitions the parent territory: first-wins per key, leftovers to part 0, fresh ids, baseline stats, copied relations, parent removed', () => {
const state = splitState();
applyNationOp(state, {
kind: 'split',
nationRef: 'de',
parts: [
{ name: 'East', territory: ['DEU-E', 'DEU-W', 'ZZZ-not-a-key'] },
{ name: 'West', territory: ['DEU-W', 'DEU-S'] },
],
});
// the parent is gone and two fresh nations exist (n1, n2 — uk/us/de were taken)
expect(state.nations.de).toBeUndefined();
expect(Object.keys(state.nations).sort()).toEqual(['n1', 'n2', 'uk', 'us']);
const east = state.nations.n1;
const west = state.nations.n2;
// East claimed DEU-E and DEU-W first (the invalid key is ignored); the
// only key no part claimed — DEU-N (DEU-S went to West) — goes to part 0
// in the parent's territory order.
expect(east.territory).toEqual(['DEU-E', 'DEU-W', 'DEU-N']);
// West's DEU-W was already claimed by East; it keeps only DEU-S.
expect(west.territory).toEqual(['DEU-S']);
// every parent key sits in exactly one part (sole ownership, nothing lost)
const all = [...east.territory, ...west.territory].sort();
expect(all).toEqual(['DEU-E', 'DEU-N', 'DEU-S', 'DEU-W']);
// fresh baseline stats (not the parent's) and inherited form of state
expect(east.stats).toEqual(NEW_NATION_STATS);
expect(west.stats).toEqual(NEW_NATION_STATS);
expect(east.type).toBe('major');
expect(west.type).toBe('major');
// both parts copied the parent's relations
expect(east.relations).toEqual({ uk: 'ally' });
expect(west.relations).toEqual({ uk: 'ally' });
// the mirror entry for the removed parent is cleaned up
expect(state.nations.uk.relations.de).toBeUndefined();
expect(state.nations.uk.relations.us).toBe('neutral'); // other entries untouched
// the player (uk) is not affected
expect(state.playerNationId).toBe('uk');
});
it('an unresolvable nationRef is a no-op (the world is untouched, never throws)', () => {
const state = splitState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'split',
nationRef: 'zzz-not-a-nation',
parts: [{ name: 'East' }, { name: 'West' }],
});
expect(state).toEqual(before);
});
it('a split named by the parent NAME works (ref resolution, not just id)', () => {
const state = splitState();
applyNationOp(state, {
kind: 'split',
nationRef: 'DE', // the name; the id is 'de'
parts: [{ name: 'East', territory: ['DEU-E'] }, { name: 'West', territory: ['DEU-W'] }],
});
expect(state.nations.de).toBeUndefined();
expect(state.nations.n1.name).toBe('East');
expect(state.nations.n2.name).toBe('West');
});
it('when the parent is the player nation, playerNationId moves to the chosen part (edits.playerPart: default 0, clamped in range)', () => {
const state = splitState();
state.playerNationId = 'de';
applyNationOp(state, {
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
});
expect(state.playerNationId).toBe('n1'); // default: the first part
const state2 = splitState();
state2.playerNationId = 'de';
applyNationOp(
state2,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
},
{ playerPart: 1 },
);
expect(state2.playerNationId).toBe('n2');
const state3 = splitState();
state3.playerNationId = 'de';
applyNationOp(
state3,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ playerPart: 99 }, // clamped down to the last part
);
expect(state3.playerNationId).toBe('n2');
const state4 = splitState();
state4.playerNationId = 'de';
applyNationOp(
state4,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ playerPart: -5 }, // clamped up to the first part
);
expect(state4.playerNationId).toBe('n1');
});
it('edits.name renames the chosen part (edits.playerPart picks which one)', () => {
const state = splitState();
applyNationOp(
state,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ playerPart: 1, name: 'New West' },
);
expect(state.nations.n1.name).toBe('East'); // untouched part keeps its name
expect(state.nations.n2.name).toBe('New West');
const state2 = splitState();
applyNationOp(
state2,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ name: 'First of the Remnant' }, // no playerPart -> the first part
);
expect(state2.nations.n1.name).toBe('First of the Remnant');
expect(state2.nations.n2.name).toBe('West');
});
it('a 1-part split is a no-op on the world (fewer than 2 parts)', () => {
const state = splitState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, { kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] });
expect(state).toEqual(before);
});
});
describe('applyNationOp (merge, v0.24 #16 P4b)', () => {
/**
* de and us will be merged (the survivor is us — deliberately listed
* second in nationRefs, so the "survivor's view wins" rule can be observed
* overwriting the first input's views). uk, fr and ja survive as third
* parties; the survivor has no view on ja, so ja must come from the first
* input's map.
*/
function mergeState(): GameState {
const s = baseState();
s.nations.de.relations = { us: 'rival', uk: 'hostile', fr: 'rival', ja: 'rival' };
s.nations.us.relations = { de: 'ally', uk: 'ally', fr: 'neutral' }; // no view on ja
s.nations.uk.relations = { us: 'ally', de: 'hostile', fr: 'neutral', ja: 'neutral' };
s.nations.fr = {
id: 'fr',
type: 'major',
name: 'FR',
stats: { military: 40, economy: 60, stability: 70, population: 60 },
territory: ['FRA'],
relations: { us: 'neutral', de: 'rival', uk: 'neutral', ja: 'neutral' },
};
s.nations.ja = {
id: 'ja',
type: 'major',
name: 'JA',
stats: { military: 30, economy: 80, stability: 50, population: 90 },
territory: ['JPN'],
relations: { us: 'neutral', de: 'rival', uk: 'neutral', fr: 'neutral' },
};
return s;
}
it('folds the inputs into the survivor: union territory (survivor first), survivor stats, survivor view wins per third party (else first input), non-input third parties kept, removed ids dropped from everyone else, into-name', () => {
const state = mergeState();
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
// the removed input is gone; the survivor and all non-inputs survive
expect(state.nations.de).toBeUndefined();
expect(Object.keys(state.nations).sort()).toEqual(['fr', 'ja', 'uk', 'us']);
// the survivor keeps its id, its own stats, and the merged name
expect(state.nations.us.id).toBe('us');
expect(state.nations.us.stats).toEqual({ military: 60, economy: 90, stability: 80, population: 80 });
expect(state.nations.us.name).toBe('Union');
// territory: the union — the survivor's keys first, then the other input's
expect(state.nations.us.territory).toEqual(['USA', 'DEU']);
// relations: on uk and fr the survivor's own views (ally/neutral) beat the
// first input's (hostile/rival); ja has no survivor view, so the first
// input's (rival) stands; the removed input's id is gone from the map.
expect(state.nations.us.relations).toEqual({ uk: 'ally', fr: 'neutral', ja: 'rival' });
// the removed id is dropped from every remaining nation's relations map
expect(state.nations.uk.relations).toEqual({ us: 'ally', fr: 'neutral', ja: 'neutral' });
expect(state.nations.fr.relations).toEqual({ us: 'neutral', uk: 'neutral', ja: 'neutral' });
expect(state.nations.ja.relations).toEqual({ us: 'neutral', uk: 'neutral', fr: 'neutral' });
// the player (uk) is not part of the merge — the pointer stays valid
expect(state.playerNationId).toBe('uk');
});
it('the player pointer moves to the survivor when the player led a removed input', () => {
const state = mergeState();
state.playerNationId = 'de'; // the player's nation is absorbed
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
expect(state.playerNationId).toBe('us');
expect(state.nations[state.playerNationId].name).toBe('Union');
});
it('edits.name overrides the merged name; an empty edit falls back to "into", then to the survivor name', () => {
const state = mergeState();
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: 'United World' });
expect(state.nations.us.name).toBe('United World');
const state2 = mergeState();
applyNationOp(state2, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: ' ' });
expect(state2.nations.us.name).toBe('Union');
const state3 = mergeState();
applyNationOp(state3, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: '' }); // empty into: keep the survivor's name
expect(state3.nations.us.name).toBe('US');
});
it('an unresolvable input ref is a no-op (never throws, world untouched)', () => {
const state = mergeState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'merge',
nationRefs: ['us', 'zzz-not-a-nation'],
survivingRef: 'uk',
into: 'X',
});
expect(state).toEqual(before);
});
it('a survivor that is not one of the inputs is a no-op', () => {
const state = mergeState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'merge',
nationRefs: ['us', 'de'],
survivingRef: 'uk', // uk is not an input
into: 'X',
});
expect(state).toEqual(before);
});
it('fewer than two DISTINCT inputs is a no-op (the same nation named twice)', () => {
const state = mergeState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'merge',
nationRefs: ['us', 'US'], // both resolve to 'us'
survivingRef: 'us',
into: 'X',
});
expect(state).toEqual(before);
});
});
describe('parseTurnResult', () => {
it('parses a fenced JSON block', () => {
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
@@ -460,6 +1038,8 @@ describe('applyTurnResult', () => {
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -474,6 +1054,8 @@ describe('applyTurnResult', () => {
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -494,6 +1076,8 @@ describe('applyTurnResult', () => {
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
nationOps: [],
};
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -514,13 +1098,17 @@ describe('applyTurnResult', () => {
stateChanges: [],
relations: [],
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
// owned by no one -> it is IGNORED (a key may not end a turn unowned).
// owned by no one -> a re-claim (v0.22, #16 P3): DE gains it. It was never
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [],
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
expect(next.nations.de.territory).not.toContain('POL');
expect(next.nations.de.territory).toContain('POL'); // re-claimed, not ignored
expect(next.unowned).toEqual([]); // never released -> not no man's land
});
});
@@ -530,6 +1118,8 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
events: [],
stateChanges: [],
relations: [],
unowned: [],
nationOps: [],
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -610,6 +1200,179 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
});
});
describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
const noEvents = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.nations.uk.territory).not.toContain('GBR');
expect(next.unowned).toEqual(['GBR']);
// nothing else moved
expect(next.nations.us.territory).toEqual(['USA']);
expect(next.nations.de.territory).toEqual(['DEU']);
});
it('a re-claim (a gain on a currently-unowned key) grants it and removes it from GameState.unowned', () => {
const state = baseState();
state.unowned = ['POL']; // released in an earlier turn
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
});
expect(next.nations.de.territory).toContain('POL');
expect(next.unowned).toEqual([]); // it left no man's land
});
it('a release and a claim of the same key in one turn: the claim wins (the key is owned)', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
unowned: ['GBR'],
});
expect(next.nations.de.territory).toContain('GBR');
expect(next.nations.uk.territory).not.toContain('GBR');
expect(next.unowned).toEqual([]);
});
it('a double claim on a released key is rejected: the key stays in no man\'s land', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [
{ nation: 'de', gain: ['GBR'], lose: [] },
{ nation: 'us', gain: ['GBR'], lose: [] },
],
unowned: ['GBR'], // released first, then both nations lunge for it
});
expect(next.nations.uk.territory).not.toContain('GBR'); // the release applied
expect(next.nations.de.territory).not.toContain('GBR'); // both claims rejected
expect(next.nations.us.territory).not.toContain('GBR');
expect(next.unowned).toEqual(['GBR']);
});
it('releasing a key no nation owns (a preset-unassigned region) is a no-op — no man\'s land can only arise from a real release', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
expect(next.unowned).toEqual([]);
for (const n of Object.values(next.nations)) expect(n.territory).not.toContain('POL');
});
it('releasing a key with two owners (a corrupt state) is a no-op', () => {
const state = baseState();
state.nations.us.territory = ['USA', 'GBR']; // uk AND us share GBR
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.nations.uk.territory).toContain('GBR');
expect(next.nations.us.territory).toContain('GBR');
expect(next.unowned).toEqual([]);
});
it('releasing a key already in no man\'s land is a no-op (it stays listed once)', () => {
const state = baseState();
state.unowned = ['POL'];
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
expect(next.unowned).toEqual(['POL']);
});
it('with a validKeys set: releasing a hallucinated key is a no-op', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['not-a-region-xyz'] }, new Set(['wales-bbbb']));
expect(next.unowned).toEqual([]);
});
it('a legacy state without the unowned field accepts a release (the result carries the field)', () => {
const state = baseState();
delete (state as { unowned?: string[] }).unowned; // simulate a pre-v0.22 row
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.unowned).toEqual(['GBR']);
expect(next.nations.uk.territory).not.toContain('GBR');
});
it('a turn with no releases leaves the existing no man\'s land list unchanged', () => {
const state = baseState();
state.unowned = ['POL'];
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
});
expect(next.unowned).toEqual(['POL']);
expect(next.nations.de.territory).toContain('GBR');
});
it('validateTurnResult dedupes and filters the unowned list (trimmed non-empty strings only)', () => {
const r = validateTurnResult({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [' a ', 'a', '', 42, null, ' b '],
});
expect(r).not.toBeNull();
expect(r!.unowned).toEqual(['a', 'b']);
});
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }),
).toBe(true);
});
it('runTurn applies a release end-to-end: the model narrates it and the key leaves its owner', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Scorch the earth on the retreat' }];
const releaseJson = JSON.stringify({
date: '1939-09-05',
events: [
{
nation: 'uk',
type: 'military',
title: 'A retreat through the wastelands',
text: 'The abandoned highlands burn and lie empty.',
outcome: 'partial',
},
],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: ['GBR'],
});
const payloads: string[] = [];
const client: LlmClient = {
chat: async (messages) => {
const user = messages.find((m) => m.role === 'user');
if (user) payloads.push(user.content as string);
return releaseJson;
},
listModels: async () => [],
};
const { state } = await runTurn({ llm: client, preset, state: original, items: original.pending });
// the event landed and the release applied
expect(state.events).toHaveLength(1);
expect(state.events[0].title).toBe('A retreat through the wastelands');
expect(state.nations.uk.territory).not.toContain('GBR');
expect(state.unowned).toEqual(['GBR']);
// on the first turn the model was shown the (empty) no-man's-land list...
const first = JSON.parse(payloads[0]) as { unowned?: string[] };
expect(first.unowned).toEqual([]);
// ...and on the next turn it is shown the released key (so it can re-claim it)
const next = await runTurn({ llm: client, preset, state, items: [] });
const second = JSON.parse(payloads[1]) as { unowned?: string[] };
expect(second.unowned).toEqual(['GBR']);
// the repeated "release" of an already-unowned key is a no-op
expect(next.state.unowned).toEqual(['GBR']);
});
});
describe('makeNeighboursExecutor (v0.21 #16 P2)', () => {
const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`);
@@ -850,12 +1613,39 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
expect(withTool).toMatch(/frontier/i);
expect(withTool).not.toMatch(/Only change borders when war/i);
});
it('(v0.22 #16 P3) both modes contract the no-man\'s-land field (release + re-claim) and constrain it to owned keys', () => {
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
// the JSON shape carries the field in both modes
expect(p).toMatch(/"unowned": \["<region key>"\]/);
// the rule explains release (narrated in events) and re-claim (ordinary gain)
expect(p).toMatch(/no man's land/i);
expect(p).toMatch(/narrate the cause in "events"/);
expect(p).toMatch(/re-claims it with a "gain"/i);
// the hard constraint: only currently-owned keys may be released
expect(p).toMatch(/only region keys that a nation currently owns/);
}
});
});
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({
date: 'x',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
}),
).toBe(true);
});
});
+444 -27
View File
@@ -7,6 +7,8 @@ import type {
EventOutcome,
EventType,
GameState,
Nation,
NationStats,
Preset,
RelationStatus,
RoundItem,
@@ -19,6 +21,7 @@ import {
isDateLike,
makeEvent,
nationForPrompt,
type NationOp,
type TurnEventSpec,
type TurnResult,
} from '../shared/protocol';
@@ -133,7 +136,9 @@ export function playerFrontier(
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
* available) the contract gains the contiguity rules for territory transfers
* (v0.21, #16 P2): the model may only move regions across a shared border, and
* may verify borders with the tool before answering.
* may verify borders with the tool before answering. The contract also
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
*/
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
const rules = preset.simulationRules
@@ -142,12 +147,23 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
const respondLine = withNeighboursTool
? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'
: 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:';
// (v0.22, #16 P3) No man's land: a region can be left unowned after a war or
// cataclysm (the model narrates the cause in "events" and lists the key in
// "unowned"); unowned regions can later be re-claimed with an ordinary gain.
// The model sees the current no-man's-land keys in the user payload's
// "unowned" field and in the "regions" reference table.
const noMansLandRule =
'- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man\'s land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man\'s land.';
const territoryRules = withNeighboursTool
? [
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns. One region key has exactly one owner at all times — never assign a key to two nations or to none.',
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.',
'- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.',
noMansLandRule,
]
: ['- Only change borders when war or a decisive political shift justifies it.'];
: [
'- Only change borders when war or a decisive political shift justifies it.',
noMansLandRule,
];
return [
`WORLD: ${preset.title} — ${preset.startDate}`,
preset.worldBackground,
@@ -164,12 +180,23 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
' "unowned": ["<region key>"],',
' "nationOps": [',
' {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},',
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
' ],',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -198,6 +225,13 @@ export function buildUserPrompt(
* contiguous targets. Omitted entirely when undefined (no adjacency data).
*/
frontier?: string[],
/**
* (v0.22, #16 P3) Region keys that are currently "no man's land" (released
* mid-game — `state.unowned`). Emitted verbatim (even when empty) so the
* model knows which unowned regions exist and can be re-claimed this turn.
* Omitted entirely when undefined.
*/
noMansLand?: string[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -235,6 +269,7 @@ export function buildUserPrompt(
};
if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier;
if (noMansLand !== undefined) payload.unowned = noMansLand;
return JSON.stringify(payload, null, 2);
}
@@ -295,6 +330,53 @@ function str(s: unknown, max = 0): string | null {
return max ? t.slice(0, max) : t;
}
/**
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
* unknown `kind` or missing/empty required fields are dropped, never throw.
* Rename: non-empty `nationRef` + `to` (each capped at 200 chars — resolution
* of `nationRef` to a concrete nation happens later, at confirm time). Split:
* non-empty `nationRef` + at least one part with a name. Merge: non-empty
* `nationRefs` (capped at 20) + `survivingRef` + `into`.
*/
export function validateNationOps(raw: unknown): NationOp[] {
if (!Array.isArray(raw)) return [];
const out: NationOp[] = [];
for (const e of raw) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (o.kind === 'rename') {
const nationRef = str(o.nationRef, 200);
const to = str(o.to, 200);
if (nationRef && to) out.push({ kind: 'rename', nationRef, to });
} else if (o.kind === 'split') {
const nationRef = str(o.nationRef, 200);
const parts = (Array.isArray(o.parts) ? o.parts : [])
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
.map((p) => {
const name = str(p.name, 200);
const territory = Array.isArray(p.territory)
? p.territory.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
: [];
return { name, territory };
})
.filter((p): p is { name: string; territory: string[] } => p.name !== null);
if (nationRef && parts.length > 0) out.push({ kind: 'split', nationRef, parts });
} else if (o.kind === 'merge') {
const nationRefs = (Array.isArray(o.nationRefs) ? o.nationRefs : [])
.map((x) => (typeof x === 'string' ? x.trim().slice(0, 200) : ''))
.filter((x) => x !== '')
.slice(0, 20);
const survivingRef = str(o.survivingRef, 200);
const into = str(o.into, 200);
if (nationRefs.length > 0 && survivingRef && into) {
out.push({ kind: 'merge', nationRefs, survivingRef, into });
}
}
}
return out;
}
/**
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
@@ -309,6 +391,8 @@ export function validateTurnResult(input: unknown): TurnResult | null {
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
};
if (Array.isArray(o.events)) {
@@ -384,12 +468,38 @@ export function validateTurnResult(input: unknown): TurnResult | null {
}
}
// (v0.22, #16 P3) Keys released to "no man's land": deduped, non-empty
// strings only (applied later by applyTurnResult against validKeys).
if (Array.isArray(o.unowned)) {
for (const k of o.unowned) {
const s = typeof k === 'string' ? k.trim() : '';
if (s && !result.unowned.includes(s)) result.unowned.push(s);
}
}
// (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
// are NOT applied by the turn — applyTurnResult appends them to the pending
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
return result;
}
/** A turn is "meaningful" if it produced at least an event or a stat change. */
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
* a proposal is a real pending-world change the player will resolve, so it
* must survive the retry/nudge path instead of being replaced by the generic
* "unclear" result).
*/
export function isMeaningful(result: TurnResult): boolean {
return result.events.length > 0 || result.stateChanges.length > 0;
return (
result.events.length > 0 ||
result.stateChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0
);
}
// --- Applying the delta -------------------------------------------------------
@@ -406,7 +516,11 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
/**
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory moves.
* events, apply stat changes (clamped), symmetric relations, and territory
* moves (including no-man's-land releases, v0.22 #16 P3). It also appends any
* proposed nation-ops to the pending list (v0.23 #16 P4a) without applying
* them — see the doc comment on `pendingNationOps` for why that list
* deliberately survives across turns.
*/
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
@@ -431,22 +545,53 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
a.relations[r.b] = r.set;
b.relations[r.a] = r.set;
}
// --- Territory (v0.21, #16 P2: a region key has exactly one owner) ---
// --- Territory (v0.21, #16 P2: at most one owner per key;
// v0.22, #16 P3: a key may also be released to "no man's land") ---
//
// Snapshot-first: BEFORE any mutation, compute who currently owns each
// region key. A key transfers only when (a) exactly one nation claims to
// gain it — a double claim (two or more gainers) is rejected so the key
// stays where it is, (b) the key currently has exactly one owner — a
// pre-existing double ownership (or unownership) is never touched, and
// (c) that owner is not the gainer (a self-gain is a no-op). A transfer
// removes the key from every nation first (defensive) and then grants it
// to the gainer; "lose" entries are pure no-ops by design, because a lone
// loss would orphan the key and a valid gain already removes it from the
// old owner. Ownership is therefore conserved: a key can never end a turn
// unowned or multiply-owned.
// Two kinds of change, applied in this order:
//
// (1) RELEASES (`result.unowned`, v0.22 #16 P3): each key that a nation
// CURRENTLY owns (exactly one owner) is removed from that owner, left
// ownerless, and added to `GameState.unowned` — the client renders it
// as the distinct no-man's-land grey. A key with zero owners (a preset
// never assigned it) is never released: doing so would fabricate a
// visible no-man's-land out of an ordinary unowned region. A key with
// two or more owners (pre-existing corruption) is never touched, and
// a key already in `unowned` is a no-op (it is already unowned).
//
// (2) CLAIMS (`gain` entries), resolved against the ownership AFTER the
// releases (snapshot-first):
// - a key with exactly one owner that is not the gainer → a TRANSFER:
// removed from the old owner, granted to the gainer (v0.21 rules);
// - a key with NO owner → a RE-CLAIM: granted to the (sole) gainer and
// removed from `GameState.unowned` (it leaves no man's land);
// - a double claim (two or more distinct gainers) is rejected — the
// key stays as it is; a self-gain is a no-op; a pre-existing double
// ownership is never touched.
//
// Invariants after the turn: no key sits in two or more territories; a
// released key sits in none; `GameState.unowned` holds exactly the
// zero-owner keys that were released mid-game (not the preset-unassigned
// ones). "lose" entries remain deliberate no-ops: releasing is the
// `unowned` list's job, and a lone loss without a release would just
// orphan a key silently.
const valid = (k: string) => !validKeys || validKeys.has(k);
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (0 or 2+ owners)
for (const n of Object.values(state.nations)) {
const unowned = new Set(state.unowned ?? []);
// (1) Releases: establish the new no-man's-land set before any claim.
for (const key of new Set(result.unowned)) {
if (!valid(key) || unowned.has(key)) continue;
const owners = Object.values(next.nations)
.filter((n) => n.territory.includes(key))
.map((n) => n.id);
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
unowned.add(key);
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
}
// (2) Claims against the post-release ownership snapshot.
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (2+ owners; 0 = absent)
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
if (!valid(key)) continue;
const cur = ownerOf.get(key);
@@ -479,16 +624,25 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
for (const [key, claiming] of claimants) {
if (claiming.size !== 1) continue; // double claim: the key stays as it was
const gainer = [...claiming][0];
const owner = ownerOf.get(key);
if (owner === undefined || owner === '' || owner === gainer) continue;
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
if (owner === '') continue; // pre-existing double ownership: never touched
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
for (const n of Object.values(next.nations)) {
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
}
next.nations[gainer].territory.push(key);
unowned.delete(key); // re-claimed: the key leaves no man's land
}
// "lose" entries are deliberate no-ops (see above): a valid gain already
// removes the key from its old owner, and a lone loss would orphan it.
next.unowned = [...unowned];
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
// are appended to the pending list, which (unlike `pending`) SURVIVES
// across turns until the player confirms or rejects each one. `next` is a
// deep clone of `state`, so `next.pendingNationOps` is a copy of whatever
// the state already held (or undefined for a legacy row) — start from that
// and append the new proposals, defaulting a missing field to `[]`.
next.pendingNationOps = [...(next.pendingNationOps ?? []), ...result.nationOps];
return next;
}
@@ -523,6 +677,265 @@ export function removeRoundItem(state: GameState, index: number | undefined): Ga
return next;
}
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes,
// player confirms" model) ----------------------------------------------------
//
// A NationOp proposed by a turn lands in `GameState.pendingNationOps` (see
// applyTurnResult) and waits there across turns. These functions resolve the
// player's decision on one pending proposal, by index. Like removeRoundItem
// they are pure: clone the state, mutate the clone, bump `version` once,
// return the new state — and return `null` for a bad index so the route can
// answer 400 without mutating anything.
/** True when `index` is a valid, in-bounds integer position in the pending list. */
function validPendingOpIndex(state: GameState, index: number): boolean {
const len = state.pendingNationOps?.length ?? 0;
return Number.isInteger(index) && index >= 0 && index < len;
}
/**
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
* write either the nation's stable `id` or its human-readable `name`) to a
* concrete nation id: an exact id match wins, then a unique case-insensitive
* name match. Returns `null` when the reference is empty, unknown, or
* ambiguous (two or more nations share that name) — every caller treats
* `null` as "no-op" and nothing here ever throws.
*/
export function resolveNationRef(state: GameState, ref: string): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (state.nations[r]) return r; // exact id first
const matches = Object.values(state.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
}
/**
* Generate a fresh, unused nation id for a newly created nation (split
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
* each id it creates to `taken` as it goes, so ids stay unique within an
* op and are never reused from a removed nation.
*/
export function freshNationId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `n${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
* a copy of the parent's stats (a small rump state is not the parent's
* military power).
*/
export const NEW_NATION_STATS: NationStats = {
military: 50,
economy: 50,
stability: 50,
population: 50,
};
/**
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
* the removed nations' ids from every remaining nation's `relations` map so
* no key dangles. (The client already degrades gracefully on a missing
* target, but consistent data is cleaner.)
*/
function dropRelationsTo(state: GameState, removedIds: ReadonlySet<string>): void {
for (const n of Object.values(state.nations)) {
for (const id of removedIds) {
if (id in n.relations) delete n.relations[id];
}
}
}
/**
* Apply one NationOp to a state, MUTATING the passed object in place (the
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
* (the route-level functions do that once per decision). It never throws:
* any unresolvable reference or malformed op is a silent no-op (the op is
* still removed by confirmNationOp — the world simply doesn't change).
*
* - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
* territory, its relations (keyed by id), and the player pointer all stay
* valid, so nothing downstream breaks; a player-supplied `edits.name`
* overrides the proposed `to` (an empty edit falls back to the proposal's
* own name).
*
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
* be at least two named parts, else no-op. Parts are processed in order;
* each receives the region keys of its `territory` that (a) the parent
* owns and (b) no earlier part claimed — the first part to name a key
* wins. Keys of the parent's territory that no part claimed go to part 0
* (in the parent's `territory` order), so the parent's territory is fully
* partitioned — no key is lost or ever held twice. Each part gets a
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
* copy of the parent's), plus a copy of the parent's relations; the
* parent is then removed. If the parent was the player's nation,
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
* into range, default 0); `edits.name` renames that same chosen part.
*
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
* resolve, at least two inputs must resolve to DISTINCT nations, and the
* survivor must be one of them — else no-op. The survivor keeps its id,
* keeps its own stats, gains the union of all inputs' territories
* (survivor's keys first, then each other input's unclaimed keys in its
* own order), and gains a union of the inputs' relations (on a third
* party the survivor's own view wins, else the first input's). The other
* inputs are removed (their ids dropped from everyone else's relations
* maps), and if the player led a removed input, `playerNationId` moves to
* the survivor. The merged name is `edits.name` (if non-empty), else
* `op.into`, else the survivor's current name.
*/
export function applyNationOp(
state: GameState,
op: NationOp,
edits?: { name?: string; playerPart?: number },
): void {
if (op.kind === 'rename') {
const id = resolveNationRef(state, op.nationRef);
if (id === null) return; // invalid/ambiguous ref: no-op
const n = state.nations[id];
if (!n) return;
const name = (edits?.name ?? '').trim() || op.to.trim();
if (name) n.name = name;
return;
}
if (op.kind === 'split') {
const id = resolveNationRef(state, op.nationRef);
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
const parent = state.nations[id];
if (!parent) return;
const taken = new Set(Object.keys(state.nations));
const created: string[] = [];
const claimed = new Set<string>();
for (const part of op.parts) {
const newId = freshNationId(taken);
taken.add(newId);
const keys = (part.territory ?? [])
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
for (const k of keys) claimed.add(k);
const partNation: Nation = {
id: newId,
type: parent.type, // a part keeps the parent's form of state
name: part.name,
stats: { ...NEW_NATION_STATS },
territory: keys,
relations: { ...parent.relations },
};
state.nations[newId] = partNation;
created.push(newId);
}
// Keys the parent owned but no part claimed go to part 0 (in the
// parent's territory order) — nothing is lost or duplicated.
for (const k of parent.territory) {
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
}
// The player chooses which part is its successor (edits.playerPart,
// clamped into range; default 0) — only relevant when the parent was
// the player's nation; the same part also gets a player-edited name.
const partIdx =
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
if (state.playerNationId === id) state.playerNationId = created[partIdx];
const editedName = (edits?.name ?? '').trim();
if (editedName) state.nations[created[partIdx]].name = editedName;
delete state.nations[id];
dropRelationsTo(state, new Set([id]));
return;
}
// merge
const resolved: string[] = [];
for (const ref of op.nationRefs) {
const rid = resolveNationRef(state, ref);
if (rid === null) return; // any unresolvable input: no-op
resolved.push(rid);
}
const inputIds = new Set(resolved);
const survivorId = resolveNationRef(state, op.survivingRef);
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
const survivor = state.nations[survivorId];
if (!survivor) return;
// Territory: the union of all inputs — the survivor's keys first (stable
// order), then each other input's unclaimed keys in its own order.
const territory = [...survivor.territory];
const seen = new Set(territory);
for (const rid of resolved) {
for (const k of state.nations[rid].territory) {
if (!seen.has(k)) {
seen.add(k);
territory.push(k);
}
}
}
// Relations: the union of all inputs' relations; the first input to name a
// third party sets it, then the survivor's own view overwrites per key.
const relations: Record<string, RelationStatus> = {};
for (const rid of resolved) {
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
if (other === survivorId) continue; // never relate to oneself
if (!(other in relations)) relations[other] = status;
}
}
for (const [other, status] of Object.entries(survivor.relations)) {
if (other !== survivorId) relations[other] = status;
}
const removed = new Set<string>();
for (const rid of inputIds) {
if (rid === survivorId) continue;
delete state.nations[rid];
removed.add(rid);
}
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
state.playerNationId = survivorId;
}
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
if (name) survivor.name = name;
survivor.territory = territory;
survivor.relations = relations;
dropRelationsTo(state, removed);
}
/**
* Confirm a pending nation-op proposal by index: apply it (a rename takes
* effect, optionally with a player-edited name; a split partitions the
* nation's territory into the new parts, the player picking which part is
* its successor via `edits.playerPart`; a merge folds the inputs into the
* survivor, optionally renamed via `edits.name`) and remove it from
* `pendingNationOps`, bumping `version`. Returns `null` for an invalid
* index, so the route answers 400 without mutating. Reused by the
* /nation-op/confirm route.
*/
export function confirmNationOp(
state: GameState,
index: number,
edits?: { name?: string; playerPart?: number },
): GameState | null {
if (!validPendingOpIndex(state, index)) return null;
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
const op = next.pendingNationOps[index];
applyNationOp(next, op, edits);
next.pendingNationOps.splice(index, 1);
return next;
}
/**
* Reject (drop) a pending nation-op proposal by index without applying it,
* bumping `version`. Returns `null` for an invalid index, so the route
* answers 400 without mutating. Reused by the /nation-op/reject route.
*/
export function rejectNationOp(state: GameState, index: number): GameState | null {
if (!validPendingOpIndex(state, index)) return null;
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pendingNationOps.splice(index, 1);
return next;
}
/**
* Run one turn end-to-end against the (injectable) LLM: build prompts, parse/
* validate, retry once with a nudge if the result is empty/invalid, and — if
@@ -558,7 +971,9 @@ export async function runTurn(opts: {
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
const user = buildUserPrompt(state, items, regionReference, frontier);
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
// list on a fresh game) so it knows which unowned regions can be re-claimed.
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -601,6 +1016,8 @@ export async function runTurn(opts: {
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
};
}
+49 -2
View File
@@ -12,13 +12,15 @@ import { deepClone } 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, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
import { createPresetStore } from './presets';
@@ -124,6 +126,14 @@ 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.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
@@ -220,6 +230,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,7 +277,12 @@ 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
+70 -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,8 @@ const state: GameState = {
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
@@ -49,6 +63,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 +167,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';
+21 -4
View File
@@ -27,9 +27,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 +51,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 +101,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
@@ -164,13 +181,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 []', () => {
+18
View File
@@ -24,6 +24,13 @@ 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[];
}
function truncate(s: string, n: number): string {
@@ -45,6 +52,15 @@ 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.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -108,6 +124,8 @@ export function deterministicTurn(p: StubPayload): TurnResult {
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
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)
};
}
+169
View File
@@ -30,6 +30,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;
@@ -46,6 +121,26 @@ export interface TurnResult {
stateChanges: StateChange[];
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[];
}
/** Per-region map payload: region key -> owning nation + its stable color. */
@@ -152,6 +247,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 +316,47 @@ export function nationColor(id: string): string {
return `hsl(${hue} 58% 46%)`;
}
// --- 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,
+33
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 {
@@ -120,6 +124,35 @@ 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[];
}
/**
+56
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,7 @@ function GameScreen({
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
unowned={game.unowned ?? []}
onFocusNation={focusNation}
/>
@@ -834,12 +887,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>
);
}
+70 -17
View File
@@ -8,7 +8,13 @@ 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 {
mapFeatureOwner,
regionFillStyle,
unownedTooltipLabel,
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
import { idToKey } from '@/lib/regions';
@@ -114,19 +120,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 +155,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;
@@ -188,14 +206,21 @@ 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,
): 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('');
return `<div class="hm-popup">
@@ -213,6 +238,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
@@ -246,6 +279,13 @@ 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.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API.
*/
@@ -255,6 +295,7 @@ export default function WorldMap({
nations,
presetId,
className,
unowned,
onFocusNation,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -268,11 +309,13 @@ 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);
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
presetRef.current = presetId;
unownedRef.current = new Set(unowned ?? []);
onFocusNationRef.current = onFocusNation;
// Diffing state for the transient territory-change flash (owned by this component
@@ -396,9 +439,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 noMansLand =
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation))
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -458,7 +506,7 @@ 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));
};
const ensureLayers = async () => {
@@ -568,7 +616,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 +628,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 +640,11 @@ 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]);
// 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`, {