Compare commits

..
8 Commits
Author SHA1 Message Date
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
21 changed files with 1146 additions and 167 deletions
+62 -32
View File
@@ -28,13 +28,15 @@ 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` - Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON) - Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national** - 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,798 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity `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 (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 `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 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` 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.
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an - The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain **injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool `chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
@@ -52,29 +54,29 @@ history/
├── eslint.config.js # flat config (ESLint 9) ├── eslint.config.js # flat config (ESLint 9)
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/ ├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases ├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 151 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) ├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
├── index.html ├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest) ├── presets/ # 5 scenario JSON files + index.json (manifest)
├── public/ ├── public/
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator) │ ├── 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) │ ├── regions.geo.json # 1,798 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-keys.json # feature id -> region key, 1,798 entries (the ownership/LLM address space, #16 P1)
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2) │ └── region-adjacency.json # region key -> bordering region keys, 1,798 entries (the getNeighbours data, #16 P2)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only; ├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth → │ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → build-region-keys → build-region-adjacency │ │ # fill-gaps → split-arkhangel → build-region-keys → build-region-adjacency
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests │ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
├── shared/ # NO runtime deps — the single source of truth ├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and │ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
│ # `nationOps`, v0.23 #16 P4a) + AddAction/AddDiplomacy/RemoveQueueItem/ │ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
│ # Jump + NationOp confirm/reject request types + pure helpers │ # Jump + NationOp confirm/reject request types + pure helpers
├── server/ ├── server/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor| │ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads │ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
│ │ # PORT/LLM_*/DATA_DIR │ │ # 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 / applyNationOp / confirmNationOp / rejectNationOp, v0.23 #16 P4a) │ ├── 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 │ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency │ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER │ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
@@ -96,7 +98,7 @@ history/
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight ├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a) ├── 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) or reject (v0.23 #16 P4a) ├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
├── EventFeed.tsx # scrolling event log (newest first) ├── EventFeed.tsx # scrolling event log (newest first)
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set ├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
├── StatBar.tsx ├── StatBar.tsx
@@ -114,6 +116,7 @@ history/
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs | | `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config | | `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) | | `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) | | `npm run 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` | | `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
@@ -188,25 +191,52 @@ history/
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner") "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` / — 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 `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). game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). Since v0.25
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a).** `TurnResult.nationOps` (required (#31) the 78-part RU-ARK mega-region is split into three features — mainland `RU-ARK` (its key
in the schema) carries the LLM’s proposed **`NationOp`s** — a discriminated union of `rename` / `arkhangel-sk-lhbk` is **unchanged**, so existing ownership is safe), `RU-ARK-NE` (New Earth /
`split` / `merge`. `rename` is fully applied; `split` and `merge` are **declared shapes only** Franz Josef Land, `new-earth-eikt`) and `RU-ARK-SZ` (Severnaya Zemlya,
(accepted and stored as pending, but a no-op until P4b implements them). Each op names its target `severnaya-zemlya-awqp`) — by the idempotent `scripts/split-arkhangel.mjs` stage (run between
via `nationRef` — the LLM may use a nation’s **id or name**; `resolveNationRef` resolves id-first, `fill:regions` and `build:region-keys`, no-op once applied); the two island groups get their own
then a case-insensitive unique-name match, and returns `null` for an unknown or ambiguous ref (such region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya
ops are dropped defensively in `validateNationOps`, never thrown). A turn’s ops are appended to Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair).
`GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals persist - **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
across turns until resolved (like the action queue). The player then decides per op via `TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
`POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?}}`) or `.../nation-op/reject` discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
(`{index}`): `confirmNationOp` applies the op to a clone (a rename sets `Nation.name` to the edited (P4b implemented split/merge). Each op names its target via `nationRef` — the LLM may use a
value or, failing that, the proposed `to`; id / territory / relations / `playerNationId` stay valid, nation’s **id or name**; `resolveNationRef` resolves id-first, then a case-insensitive
so a renamed player is re-named in place) and `rejectNationOp` just drops it — both splice by index unique-name match, and returns `null` for an unknown or ambiguous ref (such ops are dropped
and bump `version` once (an invalid index → `null` → HTTP 400). The response is `{ game }` and the defensively in `validateNationOps`, never thrown). A turn’s ops are appended to
server broadcasts a version-bumped SSE `state`, so every tab’s version-guarded `applyState` stays in `GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals
sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel) with persist across turns until resolved (like the action queue). The player then decides per op via
an editable rename name plus Confirm / Reject. The stub never proposes a nation op (`nationOps: []`), `POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?, playerPart?}}`) or
so this path is covered by unit tests only (`server/engine.test.ts`). `.../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).** - **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 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 owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
@@ -233,9 +263,9 @@ history/
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its 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 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) 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), so the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e still true since P4b implemented split/merge), so the confirm/reject path is covered by unit
assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally 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 bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so
keep the stub deterministic. keep the stub deterministic.
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The - **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
+25
View File
@@ -2,6 +2,31 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## 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 ## 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. - **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. - **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.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.23.0", "version": "0.25.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -14,6 +14,7 @@
"cleanup:regions": "node scripts/cleanup-regions.mjs", "cleanup:regions": "node scripts/cleanup-regions.mjs",
"smooth:regions": "node scripts/smooth-regions.mjs", "smooth:regions": "node scripts/smooth-regions.mjs",
"fill:regions": "node scripts/fill-gaps.mjs", "fill:regions": "node scripts/fill-gaps.mjs",
"split:regions": "node scripts/split-arkhangel.mjs",
"build:region-keys": "node scripts/build-region-keys.mjs", "build:region-keys": "node scripts/build-region-keys.mjs",
"build:region-adjacency": "node scripts/build-region-adjacency.mjs", "build:region-adjacency": "node scripts/build-region-adjacency.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
+5
View File
@@ -887,6 +887,7 @@
"komi-eoeh", "komi-eoeh",
"murmansk-etas", "murmansk-etas",
"nenets-qyga", "nenets-qyga",
"severnaya-zemlya-awqp",
"vologda-ynvr" "vologda-ynvr"
], ],
"artigas-stkw": [ "artigas-stkw": [
@@ -7614,6 +7615,7 @@
"ontario-bzqu", "ontario-bzqu",
"quebec-yegk" "quebec-yegk"
], ],
"new-earth-eikt": [],
"new-hampshire-koki": [ "new-hampshire-koki": [
"maine-maiz", "maine-maiz",
"massachusetts-ggid", "massachusetts-ggid",
@@ -10010,6 +10012,9 @@
"medea-elbf", "medea-elbf",
"skikda-odba" "skikda-odba"
], ],
"severnaya-zemlya-awqp": [
"arkhangel-sk-lhbk"
],
"sevilla-oxma": [ "sevilla-oxma": [
"badajoz-ciwt", "badajoz-ciwt",
"cadiz-aazc", "cadiz-aazc",
+2
View File
@@ -1305,6 +1305,8 @@
"RU-ALT": "altay-mfuq", "RU-ALT": "altay-mfuq",
"RU-AMU": "amur-clbp", "RU-AMU": "amur-clbp",
"RU-ARK": "arkhangel-sk-lhbk", "RU-ARK": "arkhangel-sk-lhbk",
"RU-ARK-NE": "new-earth-eikt",
"RU-ARK-SZ": "severnaya-zemlya-awqp",
"RU-AST": "astrakhan-anec", "RU-AST": "astrakhan-anec",
"RU-BA": "bashkortostan-uovk", "RU-BA": "bashkortostan-uovk",
"RU-BEL": "belgorod-wxjk", "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 * 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 * regions all named "Western") the 4-char codes are made distinct by
* re-deriving with a deterministic salt on collision, which guarantees the * 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,798 regions.
* *
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }` * Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map * 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 geo = JSON.parse(readFileSync(geoFile, 'utf8'));
const features = Array.isArray(geo?.features) ? geo.features : []; const features = Array.isArray(geo?.features) ? geo.features : [];
if (features.length !== 1796) { if (features.length !== 1798) {
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`); throw new Error(`expected 1,798 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 // 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) const western = Object.entries(manifest)
.filter(([, k]) => k.startsWith('western-')) .filter(([, k]) => k.startsWith('western-'))
.sort((a, b) => a[0] < b[0] ? -1 : 1); .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,798 expected)`);
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`); console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
+23 -1
View File
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
const adjacencyKeys = new Set(Object.keys(adjacency)); const adjacencyKeys = new Set(Object.keys(adjacency));
expect(adjacencyKeys).toEqual(manifestKeys); expect(adjacencyKeys).toEqual(manifestKeys);
expect(Object.keys(adjacency)).toHaveLength(keys.size); expect(Object.keys(adjacency)).toHaveLength(keys.size);
expect(adjacencyKeys.size).toBe(1796); expect(adjacencyKeys.size).toBe(1798);
}); });
it('every neighbour is itself a valid region key (referential integrity)', () => { it('every neighbour is itself a valid region key (referential integrity)', () => {
@@ -84,4 +84,26 @@ describe('region-adjacency.json', () => {
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true); expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true); expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
}); });
it("the #31 island splits: New Earth is isolated; Severnaya Zemlya touches only the Arkhangel's main ring", () => {
// 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). Severnaya Zemlya's
// digitized islet rings share boundary vertices with the coarsened
// mainland ring (a pre-existing intra-feature contact, exposed by the
// split) → it touches exactly its former parent, with no overlap area.
expect(adjacency['new-earth-eikt']).toEqual([]);
expect(adjacency['severnaya-zemlya-awqp']).toEqual(['arkhangel-sk-lhbk']);
// the mainland keeps its six real neighbours plus the island contact
expect(adjacency['arkhangel-sk-lhbk']).toEqual([
'karelia-ohem',
'kirov-gfvm',
'komi-eoeh',
'murmansk-etas',
'nenets-qyga',
'severnaya-zemlya-awqp',
'vologda-ynvr',
]);
});
}); });
+15 -5
View File
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
const keys = Object.values(manifest); const keys = Object.values(manifest);
const featureIds = (geo.features ?? []).map((f) => String(f.id)); const featureIds = (geo.features ?? []).map((f) => String(f.id));
it('has exactly 1,796 entries (one per region)', () => { it('has exactly 1,798 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1796); expect(Object.keys(manifest)).toHaveLength(1798);
}); });
it('has 1,796 unique keys (no collisions)', () => { it('has 1,798 unique keys (no collisions)', () => {
expect(new Set(keys).size).toBe(keys.length); expect(new Set(keys).size).toBe(keys.length);
expect(new Set(keys).size).toBe(1796); expect(new Set(keys).size).toBe(1798);
}); });
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => { it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
@@ -50,11 +50,21 @@ describe('region-keys.json', () => {
} }
}); });
it("keeps RU-ARK's key stable across the #31 split and mints the two island keys", () => {
// v0.25 (#31) split RU-ARK into main + RU-ARK-NE + RU-ARK-SZ. 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
// two new features get fresh keys derived the same way from their ids.
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');
});
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => { 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 // 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 // 1:1 with the geo file (catches drift if the geo is regenerated without
// rebuilding the keys). // rebuilding the keys).
expect(featureIds).toHaveLength(1796); expect(featureIds).toHaveLength(1798);
const manifestIds = Object.keys(manifest); const manifestIds = Object.keys(manifest);
expect(new Set(manifestIds)).toEqual(new Set(featureIds)); expect(new Set(manifestIds)).toEqual(new Set(featureIds));
}); });
+54 -12
View File
@@ -530,16 +530,20 @@ 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.25 fixed point: 1,798 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 // 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 // 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 // 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 // keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
// per-feature ids/names/parents and their order are unchanged; only the // scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
// part/ring/vertex totals and the hole's part structure moved. Pinning // across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ) — a pure
// the signature guards against a non-idempotent or destructive re-run. // re-grouping: no part/ring/vertex moves, so only the region count
// The fill is byte-deterministic per input and idempotent (a second run // changed (1,796 → 1,798); the part/ring/vertex totals, the per-feature
// on the filled file changes nothing). // ids/names/parents, and the feature order (the two new features sit
// immediately after RU-ARK) 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); const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
let verts = 0, let verts = 0,
rings = 0, rings = 0,
@@ -556,15 +560,53 @@ describe('regions.geo.json', () => {
for (const ring of poly) verts += ring.length; for (const ring of poly) verts += ring.length;
} }
} }
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill expect(regions.features.length, 'region count').toBe(1798); // v0.17: 1,796 → +2 by the #31 split
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the split
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing) 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(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']); 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 split
console.log( console.log(
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` + `\n v0.25 fixed point: ${regions.features.length} regions (1,796 + the #31 split's two islands), ` +
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`, `${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
);
});
it('has the three Arkhangel\'sk features split per #31 (RU-ARK main + New Earth + Severnaya Zemlya)', () => {
// 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).
// Pure re-grouping: all three keep parent RUS and all 78 original parts.
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');
expect(ark, 'RU-ARK (the main feature) exists').toBeDefined();
expect(ne, 'RU-ARK-NE (New Earth) exists').toBeDefined();
expect(sz, 'RU-ARK-SZ (Severnaya Zemlya) exists').toBeDefined();
for (const f of [ark!, ne!, sz!]) {
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');
// 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!),
'total parts across the three features',
).toBe(78);
// stable file order: the two new features sit immediately after RU-ARK
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');
console.log(
`\n #31 split: RU-ARK ${countParts(ark!)} parts (mainland), RU-ARK-NE ${countParts(ne!)} parts (New Earth), ` +
`RU-ARK-SZ ${countParts(sz!)} parts (Severnaya Zemlya)`,
); );
}); });
}); });
+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}`);
+298 -7
View File
@@ -4,13 +4,16 @@ import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm'; import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub'; import { makeStubLlmClient } from './stub';
import { import {
applyNationOp,
applyTurnResult, applyTurnResult,
buildSystemPrompt, buildSystemPrompt,
buildUserPrompt, buildUserPrompt,
confirmNationOp, confirmNationOp,
freshNationId,
GET_NEIGHBOURS_TOOL, GET_NEIGHBOURS_TOOL,
isMeaningful, isMeaningful,
makeNeighboursExecutor, makeNeighboursExecutor,
NEW_NATION_STATS,
parseTurnResult, parseTurnResult,
playerFrontier, playerFrontier,
queueRoundItem, queueRoundItem,
@@ -634,7 +637,7 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
expect(next!.nations.us.name).toBe('United States'); expect(next!.nations.us.name).toBe('United States');
}); });
it('confirm is a no-op on the world for a P4b-kind op (split/merge) — the op is still removed', () => { 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(); const state = baseState();
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }]; state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
const next = confirmNationOp(state, 0); const next = confirmNationOp(state, 0);
@@ -683,18 +686,306 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
}); });
}); });
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a)', () => { describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
it('both modes contract the nationOps field and the rename proposal rule', () => { 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)]) { for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
// the JSON shape carries the field in both modes // the JSON shape carries all three kinds in both modes
expect(p).toMatch(/"nationOps": \[\{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"\}\]/); expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
// the rule explains that it is a proposal only the player can confirm 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(/PROPOSAL/);
expect(p).toMatch(/only takes effect if the player confirms/); 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', () => { describe('parseTurnResult', () => {
it('parses a fenced JSON block', () => { it('parses a fenced JSON block', () => {
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```'); const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
+204 -24
View File
@@ -7,6 +7,8 @@ import type {
EventOutcome, EventOutcome,
EventType, EventType,
GameState, GameState,
Nation,
NationStats,
Preset, Preset,
RelationStatus, RelationStatus,
RoundItem, RoundItem,
@@ -134,7 +136,9 @@ export function playerFrontier(
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool * When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
* available) the contract gains the contiguity rules for territory transfers * 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 * (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 { export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
const rules = preset.simulationRules const rules = preset.simulationRules
@@ -178,14 +182,21 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],', ' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],', ' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
' "unowned": ["<region key>"],', ' "unowned": ["<region key>"],',
' "nationOps": [{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"}]', ' "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', 'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.', '- 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 and unowned.', '- 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".', '- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- To rename a nation, add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} to "nationOps". This is a PROPOSAL: it only takes effect if the player confirms it, so use it only when the narrative justifies it (a revolution, reunification, regime change) — at most 1-2 per turn — and narrate it 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.', '- 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 your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.', '- Keep every stat value between 0 and 100.',
@@ -700,39 +711,208 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
return matches.length === 1 ? matches[0].id : null; 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 * Apply one NationOp to a state, MUTATING the passed object in place (the
* caller passes a clone — confirmNationOp does) and NOT bumping `version` * caller passes a clone — confirmNationOp does) and NOT bumping `version`
* (the route-level functions do that once per decision). Rename changes only * (the route-level functions do that once per decision). It never throws:
* `Nation.name` — the id, its territory, its relations (keyed by id), and the * any unresolvable reference or malformed op is a silent no-op (the op is
* player pointer all stay valid, so nothing downstream breaks; a * still removed by confirmNationOp — the world simply doesn't change).
* player-supplied `edits.name` overrides the proposed `to` (an empty edit *
* falls back to the proposal's own name). An unresolvable or ambiguous * - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
* `nationRef` is a silent no-op. Split and merge are declared for P4b and are * territory, its relations (keyed by id), and the player pointer all stay
* a no-op until then — the op stays pending-free once the player resolves it, * valid, so nothing downstream breaks; a player-supplied `edits.name`
* but the world does not change yet. * 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 }): void { export function applyNationOp(
if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet state: GameState,
const id = resolveNationRef(state, op.nationRef); op: NationOp,
if (id === null) return; // invalid/ambiguous ref: no-op edits?: { name?: string; playerPart?: number },
const n = state.nations[id]; ): void {
if (!n) return; if (op.kind === 'rename') {
const name = (edits?.name ?? '').trim() || op.to.trim(); const id = resolveNationRef(state, op.nationRef);
if (name) n.name = name; 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 * Confirm a pending nation-op proposal by index: apply it (a rename takes
* effect, optionally with a player-edited name; split/merge are a no-op until * effect, optionally with a player-edited name; a split partitions the
* P4b) and remove it from `pendingNationOps`, bumping `version`. Returns * nation's territory into the new parts, the player picking which part is
* `null` for an invalid index, so the route answers 400 without mutating. * its successor via `edits.playerPart`; a merge folds the inputs into the
* Reused by the /nation-op/confirm route. * 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( export function confirmNationOp(
state: GameState, state: GameState,
index: number, index: number,
edits?: { name?: string }, edits?: { name?: string; playerPart?: number },
): GameState | null { ): GameState | null {
if (!validPendingOpIndex(state, index)) return null; if (!validPendingOpIndex(state, index)) return null;
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
+8 -4
View File
@@ -230,10 +230,14 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
res.json({ game: next }); res.json({ game: next });
}); });
// (v0.23, #16 P4a) Resolve a pending nation-op proposal (a rename the LLM // (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
// proposed in an earlier turn, waiting in game.pendingNationOps). No LLM call, // waiting in game.pendingNationOps: a rename takes effect (body.edits.name
// no turn advance — same fast save → broadcast → respond order as // may override the proposed name), a split partitions the nation's territory
// /queue/remove. The version bump lets "never go backwards" clients reconcile. // 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) => { app.post('/api/games/:id/nation-op/confirm', (req, res) => {
const id = req.params.id; const id = req.params.id;
const game = db.load(id); const game = db.load(id);
+16 -4
View File
@@ -27,9 +27,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(loadRegions(root)).toBe(true); expect(loadRegions(root)).toBe(true);
}); });
it('loads the full manifest: 1,796 unique valid region keys', () => { it('loads the full manifest: 1,798 unique valid region keys', () => {
expect(hasRegionData()).toBe(true); expect(hasRegionData()).toBe(true);
expect(validKeys().size).toBe(1796); expect(validKeys().size).toBe(1798);
}); });
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => { it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
@@ -51,6 +51,18 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(ref['aisne-gstu']).toBe('Aisne (FRA)'); expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
}); });
it('expands RUS to all 82 sub-regions after the #31 Arkhangel split (no ownership gap at game init)', () => {
// #31 split RU-ARK into mainland + New Earth + Severnaya Zemlya. A fresh
// game's RUS territory (expanded from the preset's country list) must
// contain all three sub-region keys — the main one (ownership-safe,
// unchanged key) plus the two new island ones.
const rus = expandCountriesToKeys(['RUS']);
expect(rus).toHaveLength(82);
expect(rus).toContain('arkhangel-sk-lhbk');
expect(rus).toContain('new-earth-eikt');
expect(rus).toContain('severnaya-zemlya-awqp');
});
it('referenceForKeys labels only known keys and omits unknown ones', () => { it('referenceForKeys labels only known keys and omits unknown ones', () => {
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']); const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
expect(ref['ain-lqtt']).toBe('Ain (FRA)'); expect(ref['ain-lqtt']).toBe('Ain (FRA)');
@@ -166,13 +178,13 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
it('loads from dist/ when public/ is absent (the prod container layout)', () => { it('loads from dist/ when public/ is absent (the prod container layout)', () => {
expect(loadRegions(prodRoot)).toBe(true); expect(loadRegions(prodRoot)).toBe(true);
expect(hasRegionData()).toBe(true); expect(hasRegionData()).toBe(true);
expect(validKeys().size).toBe(1796); expect(validKeys().size).toBe(1798);
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42); expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
}); });
it('still loads from public/ (the dev layout, the first candidate)', () => { it('still loads from public/ (the dev layout, the first candidate)', () => {
expect(loadRegions(realRoot)).toBe(true); expect(loadRegions(realRoot)).toBe(true);
expect(validKeys().size).toBe(1796); expect(validKeys().size).toBe(1798);
}); });
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => { it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
+39 -24
View File
@@ -30,15 +30,14 @@ export interface TerritoryChange {
lose: string[]; lose: string[];
} }
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, player // --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
// confirms" model) ----------------------------------------------------------- // proposes, player confirms" model) ------------------------------------------
// //
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is // 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 // NOT applied by that turn. It is stored as a *pending proposal* in
// `GameState.pendingNationOps` and only takes effect when the player confirms // `GameState.pendingNationOps` and only takes effect when the player confirms
// it (optionally editing it) or is dropped when the player rejects it. Rename // it (optionally editing it) or is dropped when the player rejects it. All
// is implemented in v0.23 (P4a); split and merge are declared here so the union // three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
// is stable for P4b, and are defensively accepted-but-not-applied until then.
/** /**
* The identity reference a nation is addressed by inside a {@link NationOp}. * The identity reference a nation is addressed by inside a {@link NationOp}.
@@ -63,9 +62,13 @@ export interface RenameOp {
} }
/** /**
* A **split** proposal (declared for #16 P4b; **not** applied by the engine yet * A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
* — it is accepted into the pending list, but confirming it is a no-op until * nation is divided into. On confirm the source is dissolved: each part gets a
* P4b lands). Describes the new nations a source nation is divided into. * 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 { export interface SplitOp {
kind: 'split'; kind: 'split';
@@ -76,9 +79,13 @@ export interface SplitOp {
} }
/** /**
* A **merge** proposal (declared for #16 P4b; **not** applied by the engine yet * A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
* — accepted into the pending list, no-op on confirm until P4b). Folds several * surviving nation. On confirm the survivor keeps its id (renamed to `into`,
* nations into a single surviving nation. * 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 { export interface MergeOp {
kind: 'merge'; kind: 'merge';
@@ -93,8 +100,8 @@ export interface MergeOp {
/** /**
* A structural operation on the set of nations that the LLM may propose in a * 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 * turn (`TurnResult.nationOps`). See the section comment above for the hybrid
* propose→confirm semantics. Rename is implemented (P4a); split/merge are * propose→confirm semantics. All three kinds are implemented: rename (P4a),
* declared for P4b and are a safe no-op until then. * and split / merge (P4b, v0.24).
*/ */
export type NationOp = RenameOp | SplitOp | MergeOp; export type NationOp = RenameOp | SplitOp | MergeOp;
@@ -125,12 +132,13 @@ export interface TurnResult {
*/ */
unowned: string[]; unowned: string[];
/** /**
* (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These * (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
* are NOT applied by the turn: each is appended to * turn. These are NOT applied by the turn: each is appended to
* `GameState.pendingNationOps` as a proposal awaiting the player's * `GameState.pendingNationOps` as a proposal awaiting the player's
* confirm/reject. Rename is the only kind the engine currently applies; * confirm/reject. All three kinds (rename, split, merge) are implemented —
* split/merge are declared for P4b and are accepted-but-dropped. Empty on * a confirmed rename changes `Nation.name`; a confirmed split dissolves the
* turns where the model proposes none. * source into its parts; a confirmed merge folds its inputs into the
* survivor. Empty on turns where the model proposes none.
*/ */
nationOps: NationOp[]; nationOps: NationOp[];
} }
@@ -240,15 +248,22 @@ export interface RemoveQueueItemResponse {
} }
/** /**
* (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in * (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
* `game.pendingNationOps`. A rename takes effect (the player may override the * index in `game.pendingNationOps`. A rename takes effect (the player may
* proposed name via `edits.name`); split/merge proposals are a no-op on * override the proposed name via `edits.name`); a split and a merge are fully
* confirm until P4b. No LLM call, no turn advance. * applied (v0.24 P4b). No LLM call, no turn advance.
*/ */
export interface ConfirmNationOpRequest { export interface ConfirmNationOpRequest {
index: number; index: number;
/** Optional player edits applied on confirm (rename: the final display name). */ /**
edits?: { name?: string }; * 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 { export interface ConfirmNationOpResponse {
game: GameState; game: GameState;
+13 -12
View File
@@ -138,18 +138,19 @@ export interface GameState {
*/ */
unowned: string[]; unowned: string[];
/** /**
* (v0.23, #16 P4a) Nation-management ops the LLM has proposed but the player * (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
* has not yet resolved: a *pending proposal* list, as opposed to `pending` * proposed but the player has not yet resolved: a *pending proposal* list,
* (the current round's queued player actions). A turn NEVER applies one — * as opposed to `pending` (the current round's queued player actions). A
* `applyTurnResult` appends the turn's `nationOps` to this list, and each * turn NEVER applies one — `applyTurnResult` appends the turn's
* proposal survives across turns until the player resolves it: confirming * `nationOps` to this list, and each proposal survives across turns until
* applies it (a rename changes `Nation.name` only — the id, its territory, * the player resolves it: confirming applies it (a rename changes
* its relations, and the player pointer all stay valid) and removes it; * `Nation.name` only; a split partitions the nation's region-key territory
* rejecting removes it without applying. Split/merge are declared for P4b * into the new nations' territories; a merge folds the inputs' territory
* and are a safe no-op on confirm until then. Always `[]` on a fresh game; * into the survivor) and removes it; rejecting removes it without
* rows saved before v0.23 load with this defaulted to `[]` (see server/db.ts). * applying. Always `[]` on a fresh game; rows saved before v0.23 load with
* (The `NationOp` type lives in protocol.ts; the import there is type-only, * this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
* so this file still has no runtime dependencies.) * protocol.ts; the import there is type-only, so this file still has no
* runtime dependencies.)
*/ */
pendingNationOps: NationOp[]; pendingNationOps: NationOp[];
} }
+6 -5
View File
@@ -405,11 +405,12 @@ export default function App() {
[game, applyState], [game, applyState],
); );
// Apply a pending nation op the model proposed (confirm, with an optional // Apply a pending nation op the model proposed (confirm; `edits.name`
// rename-name edit). Fast, non-LLM — gated by `opBusy`, not `busy`. The // overrides a rename/merge name, `edits.playerPart` picks the player's
// version-bumped state comes back in the response and the SSE `state` event. // 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( const runConfirmNationOp = useCallback(
async (index: number, edits?: { name?: string }) => { async (index: number, edits?: { name?: string; playerPart?: number }) => {
if (!game) return; if (!game) return;
setOpBusy(true); setOpBusy(true);
try { try {
@@ -603,7 +604,7 @@ interface GameScreenProps {
onRemoveQueueItem: (index: number) => void; onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void; onAdvisor: (p?: string) => void;
onJump: () => void; onJump: () => void;
onConfirmNationOp: (index: number, edits?: { name?: string }) => void; onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
onRejectNationOp: (index: number) => void; onRejectNationOp: (index: number) => void;
onHome: () => void; onHome: () => void;
} }
+6 -2
View File
@@ -44,8 +44,12 @@ interface Props {
onAdvisor: (prompt?: string) => void; onAdvisor: (prompt?: string) => void;
/** Commit the whole pending round in a single LLM turn. */ /** Commit the whole pending round in a single LLM turn. */
onJump: () => void; onJump: () => void;
/** Apply a pending nation op; `edits.name` optionally overrides a rename. */ /**
onConfirmNationOp: (index: number, edits?: { name?: string }) => 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. */ /** Drop (reject) a pending nation op by its index. */
onRejectNationOp: (index: number) => void; onRejectNationOp: (index: number) => void;
/** /**
+176 -25
View File
@@ -19,8 +19,12 @@ interface Props {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** A confirm/reject request is in flight — disable the per-op controls. */ /** A confirm/reject request is in flight — disable the per-op controls. */
opBusy: boolean; opBusy: boolean;
/** Apply a pending op; `edits.name` optionally overrides a rename's target name. */ /**
onConfirm: (index: number, edits?: { name?: string }) => void; * 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). */ /** Drop a pending op (reject / dismiss). */
onReject: (index: number) => void; onReject: (index: number) => void;
} }
@@ -37,10 +41,12 @@ function describeOp(op: NationOp, name: (ref: string) => string): string {
/** /**
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`) * Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
* and lets the player apply ("LLM proposes, player confirms") or drop each one. * and lets the player apply ("LLM proposes, player confirms") or drop each one:
* A rename shows the model's proposed name in an editable field (so the player * a rename shows the proposed name in an editable field; a split lists the
* can tweak it before confirming); split and merge are declared but not yet * proposed parts (with the number of regions each is assigned) and, when the
* applied (P4b) — they can be dismissed but not confirmed. * 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({ export default function NationOpDialog({
game, game,
@@ -52,9 +58,24 @@ export default function NationOpDialog({
}: Props) { }: Props) {
const ops = game.pendingNationOps; const ops = game.pendingNationOps;
const name = (ref: string) => game.nations[ref]?.name ?? ref; const name = (ref: string) => game.nations[ref]?.name ?? ref;
// The player's edit to a rename's target name, keyed by pending-op index. // Resolve a nation ref the way the server does (id first, then a unique
// An unset entry means "use the model's proposed name" (`op.to`). // 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 [edits, setEdits] = useState<Record<number, string>>({});
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -68,7 +89,8 @@ export default function NationOpDialog({
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
The model proposed changes to nations. Confirm to apply them (you can edit a The model proposed changes to nations. Confirm to apply them (you can edit a
rename) or reject to leave the world as it is. name and choose your successor state in a split) or reject to leave the world
as it is.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -80,7 +102,34 @@ export default function NationOpDialog({
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list"> <ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
{ops.map((op, i) => { {ops.map((op, i) => {
const isRename = op.kind === 'rename'; 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() : ''; 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 ( return (
<li <li
key={`${i}-${op.kind}`} key={`${i}-${op.kind}`}
@@ -96,6 +145,7 @@ export default function NationOpDialog({
{describeOp(op, name)} {describeOp(op, name)}
</span> </span>
</div> </div>
{isRename ? ( {isRename ? (
<div className="space-y-2.5"> <div className="space-y-2.5">
<Input <Input
@@ -118,7 +168,72 @@ export default function NationOpDialog({
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={() => onConfirm(i, finalName ? { name: finalName } : undefined)} 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} disabled={opBusy}
data-testid="nation-op-confirm" data-testid="nation-op-confirm"
> >
@@ -128,21 +243,57 @@ export default function NationOpDialog({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between gap-2"> <div className="space-y-2.5">
<span className="text-[11px] text-muted-foreground"> <div className="flex flex-wrap gap-1.5">
{op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it. {(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
</span> const isSurvivor = resolveRef(ref) === survivorId;
<Button return (
variant="ghost" <Badge
size="sm" key={ri}
onClick={() => onReject(i)} variant={isSurvivor ? 'default' : 'secondary'}
disabled={opBusy} className="text-[11px]"
className="text-muted-foreground hover:text-destructive" data-testid="nation-op-input"
data-testid="nation-op-reject" >
> {name(ref)}
<X className="size-3.5" /> {isSurvivor ? ' (survivor)' : ''}
Dismiss </Badge>
</Button> );
})}
</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> </div>
)} )}
</li> </li>
+7 -4
View File
@@ -62,10 +62,13 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ index }), body: JSON.stringify({ index }),
}), }),
// (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in // (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
// `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the // its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
// proposed name). Fast, non-LLM — no turn advance. Returns the updated game. // overrides the proposed name), a split partitions the nation's territory
confirmNationOp: (id: string, index: number, edits?: { name?: string }) => // (`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`, { req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }), body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),