Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867b2eb0a1 | ||
|
|
8fb8984d07 | ||
|
|
f593af5ba5 | ||
|
|
c63f6acca1 | ||
|
|
c0c620a54c | ||
|
|
7eaed42d51 | ||
|
|
14c5d4881d | ||
|
|
458e9be419 | ||
|
|
f72a59bf5e |
@@ -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`
|
||||
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
|
||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
||||
choropleth from `public/regions.geo.json` (1,796 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||||
choropleth from `public/regions.geo.json` (1,798 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||||
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
|
||||
(v0.19, #16 P1): every sub-region is addressed by a stable key from
|
||||
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
|
||||
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
|
||||
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
|
||||
function-calling tool) instead of whole countries.
|
||||
function-calling tool) instead of whole countries. Since v0.25 (#31) the old RU-ARK
|
||||
mega-region is split into the mainland (`RU-ARK`, key unchanged) plus the New Earth
|
||||
(`RU-ARK-NE`) and Severnaya Zemlya (`RU-ARK-SZ`) island groups.
|
||||
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
||||
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
|
||||
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
||||
@@ -52,29 +54,29 @@ history/
|
||||
├── eslint.config.js # flat config (ESLint 9)
|
||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 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)
|
||||
├── index.html
|
||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||
├── public/
|
||||
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
||||
│ ├── regions.geo.json # 1,796 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||
│ ├── region-keys.json # feature id -> region key, 1,796 entries (the ownership/LLM address space, #16 P1)
|
||||
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2)
|
||||
│ ├── regions.geo.json # 1,798 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||
│ ├── 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,798 entries (the getNeighbours data, #16 P2)
|
||||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||
│ │ # fill-gaps → build-region-keys → build-region-adjacency
|
||||
│ │ # fill-gaps → split-arkhangel → build-region-keys → build-region-adjacency
|
||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
||||
├── shared/ # NO runtime deps — the single source of truth
|
||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema (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
|
||||
├── server/
|
||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||
│ │ # PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / 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
|
||||
│ ├── 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
|
||||
@@ -96,7 +98,7 @@ history/
|
||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "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)
|
||||
├── 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
|
||||
@@ -114,6 +116,7 @@ history/
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||
| `npm run lint` | ESLint flat config |
|
||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
|
||||
| `npm run 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` |
|
||||
|
||||
@@ -188,25 +191,52 @@ history/
|
||||
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
||||
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
||||
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
||||
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
|
||||
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a).** `TurnResult.nationOps` (required
|
||||
in the schema) carries the LLM’s proposed **`NationOp`s** — a discriminated union of `rename` /
|
||||
`split` / `merge`. `rename` is fully applied; `split` and `merge` are **declared shapes only**
|
||||
(accepted and stored as pending, but a no-op until P4b implements them). Each op names its target
|
||||
via `nationRef` — the LLM may use a nation’s **id or name**; `resolveNationRef` resolves id-first,
|
||||
then a case-insensitive unique-name match, and returns `null` for an unknown or ambiguous ref (such
|
||||
ops are dropped defensively in `validateNationOps`, never thrown). A turn’s ops are appended to
|
||||
`GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals persist
|
||||
across turns until resolved (like the action queue). The player then decides per op via
|
||||
`POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?}}`) or `.../nation-op/reject`
|
||||
(`{index}`): `confirmNationOp` applies the op to a clone (a 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) 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) with
|
||||
an editable rename name plus Confirm / Reject. The stub never proposes a nation op (`nationOps: []`),
|
||||
so this path is covered by unit tests only (`server/engine.test.ts`).
|
||||
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). Since v0.25
|
||||
(#31) the 78-part RU-ARK mega-region is split into three features — mainland `RU-ARK` (its key
|
||||
`arkhangel-sk-lhbk` is **unchanged**, so existing ownership is safe), `RU-ARK-NE` (New Earth /
|
||||
Franz Josef Land, `new-earth-eikt`) and `RU-ARK-SZ` (Severnaya Zemlya,
|
||||
`severnaya-zemlya-awqp`) — by the idempotent `scripts/split-arkhangel.mjs` stage (run between
|
||||
`fill:regions` and `build:region-keys`, no-op once applied); the two island groups get their own
|
||||
region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya
|
||||
Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair).
|
||||
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
|
||||
`TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
|
||||
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
|
||||
(P4b implemented split/merge). Each op names its target via `nationRef` — the LLM may use a
|
||||
nation’s **id or name**; `resolveNationRef` resolves id-first, then a case-insensitive
|
||||
unique-name match, and returns `null` for an unknown or ambiguous ref (such ops are dropped
|
||||
defensively in `validateNationOps`, never thrown). A turn’s ops are appended to
|
||||
`GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals
|
||||
persist across turns until resolved (like the action queue). The player then decides per op via
|
||||
`POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?, playerPart?}}`) or
|
||||
`.../nation-op/reject` (`{index}`): `confirmNationOp` applies the op to a clone — **rename** sets
|
||||
`Nation.name` to the edited value or, failing that, the proposed `to` (id / territory / relations /
|
||||
`playerNationId` stay valid, so a renamed player is re-named in place); **split** (the parent
|
||||
must resolve and have ≥2 named parts, else a no-op) partitions the parent’s region-key territory
|
||||
into new nations in parts order — each new nation gets a fresh id (`freshNationId`: first free of
|
||||
`n1`, `n2`, …) and the baseline `NEW_NATION_STATS` (not a copy of the parent’s), plus a copy of
|
||||
the parent’s relations; a key named by several parts goes to the first, and the parent’s
|
||||
unclaimed keys go to part 0 (in territory order), so sole ownership holds and nothing is
|
||||
orphaned; if the parent was the player’s nation, `playerNationId` moves to the part chosen by
|
||||
`edits.playerPart` (clamped into range, default 0), which also receives `edits.name`; **merge**
|
||||
(every ref must resolve, ≥2 DISTINCT inputs, and the survivor must be one of them, else a
|
||||
no-op) folds the inputs into the survivor, which keeps its id and its own stats, gains the
|
||||
union of the inputs’ territories (the survivor’s keys first) and a union of their relations
|
||||
(the survivor’s own view of a third party wins, else the first input’s), is renamed
|
||||
`edits.name` → `into` → its own name, while the other inputs are removed and their ids dropped
|
||||
from every remaining nation’s relations maps; if the player led a removed input,
|
||||
`playerNationId` moves to the survivor. An op that is invalid at confirm time (an ambiguous ref,
|
||||
…) is a no-op on the world but is still removed — and `rejectNationOp` just drops it; both
|
||||
splice by index and bump `version` once (an invalid index → `null` → HTTP 400). The response is
|
||||
`{ game }` and the server broadcasts a version-bumped SSE `state`, so every tab’s
|
||||
version-guarded `applyState` stays in sync. The client lists the pending ops in a “Nation
|
||||
proposals” dialog (in the “Your move” panel): a rename and a merge show an editable name field
|
||||
(the merge also highlights the survivor among the input chips); a split lists each part (name +
|
||||
proposed region count) with a successor radio that appears only when the parent is the player’s
|
||||
own nation (preselected part 0 — the choice is always valid). The system prompt documents all
|
||||
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
|
||||
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
|
||||
(`server/engine.test.ts`).
|
||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||
@@ -233,9 +263,9 @@ history/
|
||||
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
|
||||
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
|
||||
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a)
|
||||
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge), so
|
||||
the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e
|
||||
assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally
|
||||
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
|
||||
still true since P4b implemented split/merge), so the confirm/reject path is covered by unit
|
||||
tests only, mirroring the P3 approach; all e2e assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally
|
||||
bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so
|
||||
keep the stub deterministic.
|
||||
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
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
|
||||
- **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.
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.23.0",
|
||||
"version": "0.25.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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",
|
||||
"smooth:regions": "node scripts/smooth-regions.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-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -887,6 +887,7 @@
|
||||
"komi-eoeh",
|
||||
"murmansk-etas",
|
||||
"nenets-qyga",
|
||||
"severnaya-zemlya-awqp",
|
||||
"vologda-ynvr"
|
||||
],
|
||||
"artigas-stkw": [
|
||||
@@ -7614,6 +7615,7 @@
|
||||
"ontario-bzqu",
|
||||
"quebec-yegk"
|
||||
],
|
||||
"new-earth-eikt": [],
|
||||
"new-hampshire-koki": [
|
||||
"maine-maiz",
|
||||
"massachusetts-ggid",
|
||||
@@ -10010,6 +10012,9 @@
|
||||
"medea-elbf",
|
||||
"skikda-odba"
|
||||
],
|
||||
"severnaya-zemlya-awqp": [
|
||||
"arkhangel-sk-lhbk"
|
||||
],
|
||||
"sevilla-oxma": [
|
||||
"badajoz-ciwt",
|
||||
"cadiz-aazc",
|
||||
|
||||
@@ -1305,6 +1305,8 @@
|
||||
"RU-ALT": "altay-mfuq",
|
||||
"RU-AMU": "amur-clbp",
|
||||
"RU-ARK": "arkhangel-sk-lhbk",
|
||||
"RU-ARK-NE": "new-earth-eikt",
|
||||
"RU-ARK-SZ": "severnaya-zemlya-awqp",
|
||||
"RU-AST": "astrakhan-anec",
|
||||
"RU-BA": "bashkortostan-uovk",
|
||||
"RU-BEL": "belgorod-wxjk",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@
|
||||
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
||||
* regions all named "Western") the 4-char codes are made distinct by
|
||||
* re-deriving with a deterministic salt on collision, which guarantees the
|
||||
* full key is unique across all 1,796 regions.
|
||||
* full key is unique across all 1,798 regions.
|
||||
*
|
||||
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
||||
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
||||
@@ -107,8 +107,8 @@ const geoFile = path.join(root, 'public', 'regions.geo.json');
|
||||
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
||||
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||
|
||||
if (features.length !== 1796) {
|
||||
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
||||
if (features.length !== 1798) {
|
||||
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
|
||||
@@ -154,5 +154,5 @@ writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
|
||||
const western = Object.entries(manifest)
|
||||
.filter(([, k]) => k.startsWith('western-'))
|
||||
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
|
||||
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,798 expected)`);
|
||||
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
|
||||
const adjacencyKeys = new Set(Object.keys(adjacency));
|
||||
expect(adjacencyKeys).toEqual(manifestKeys);
|
||||
expect(Object.keys(adjacency)).toHaveLength(keys.size);
|
||||
expect(adjacencyKeys.size).toBe(1796);
|
||||
expect(adjacencyKeys.size).toBe(1798);
|
||||
});
|
||||
|
||||
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['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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
|
||||
const keys = Object.values(manifest);
|
||||
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
||||
|
||||
it('has exactly 1,796 entries (one per region)', () => {
|
||||
expect(Object.keys(manifest)).toHaveLength(1796);
|
||||
it('has exactly 1,798 entries (one per region)', () => {
|
||||
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(1796);
|
||||
expect(new Set(keys).size).toBe(1798);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// The manifest is looked up by feature id, so its key set must line up
|
||||
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
||||
// rebuilding the keys).
|
||||
expect(featureIds).toHaveLength(1796);
|
||||
expect(featureIds).toHaveLength(1798);
|
||||
const manifestIds = Object.keys(manifest);
|
||||
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
||||
});
|
||||
|
||||
+54
-12
@@ -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
|
||||
// fixed point: it never moves an existing border, merges a part, or
|
||||
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
|
||||
// keeps the single KZ-KZY hole. The region count (1,796), the
|
||||
// per-feature ids/names/parents and their order are unchanged; only the
|
||||
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
||||
// the signature guards against a non-idempotent or destructive re-run.
|
||||
// The fill is byte-deterministic per input and idempotent (a second run
|
||||
// on the filled file changes nothing).
|
||||
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
|
||||
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
|
||||
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ) — a pure
|
||||
// re-grouping: no part/ring/vertex moves, so only the region count
|
||||
// changed (1,796 → 1,798); the part/ring/vertex totals, the per-feature
|
||||
// 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);
|
||||
let verts = 0,
|
||||
rings = 0,
|
||||
@@ -556,15 +560,53 @@ describe('regions.geo.json', () => {
|
||||
for (const ring of poly) verts += ring.length;
|
||||
}
|
||||
}
|
||||
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
|
||||
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
|
||||
expect(regions.features.length, 'region count').toBe(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; 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(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
||||
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
||||
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175
|
||||
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175; unchanged by the split
|
||||
console.log(
|
||||
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
||||
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
||||
`\n v0.25 fixed point: ${regions.features.length} regions (1,796 + the #31 split's two islands), ` +
|
||||
`${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)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -4,13 +4,16 @@ import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import {
|
||||
applyNationOp,
|
||||
applyTurnResult,
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
confirmNationOp,
|
||||
freshNationId,
|
||||
GET_NEIGHBOURS_TOOL,
|
||||
isMeaningful,
|
||||
makeNeighboursExecutor,
|
||||
NEW_NATION_STATS,
|
||||
parseTurnResult,
|
||||
playerFrontier,
|
||||
queueRoundItem,
|
||||
@@ -634,7 +637,7 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||||
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();
|
||||
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
||||
const next = confirmNationOp(state, 0);
|
||||
@@ -683,18 +686,306 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a)', () => {
|
||||
it('both modes contract the nationOps field and the rename proposal rule', () => {
|
||||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
|
||||
it('both modes contract the nationOps field with all three op kinds and the proposal rule', () => {
|
||||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||
// the JSON shape carries the field in both modes
|
||||
expect(p).toMatch(/"nationOps": \[\{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"\}\]/);
|
||||
// the rule explains that it is a proposal only the player can confirm
|
||||
// the JSON shape carries all three kinds in both modes
|
||||
expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
|
||||
expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
|
||||
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
|
||||
// the rule explains that ops are proposals only the player can confirm
|
||||
expect(p).toMatch(/PROPOSAL/);
|
||||
expect(p).toMatch(/only 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', () => {
|
||||
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```');
|
||||
|
||||
+204
-24
@@ -7,6 +7,8 @@ import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Nation,
|
||||
NationStats,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
RoundItem,
|
||||
@@ -134,7 +136,9 @@ export function playerFrontier(
|
||||
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
||||
* available) the contract gains the contiguity rules for territory transfers
|
||||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||||
* may verify borders with the tool before answering.
|
||||
* may verify borders with the tool before answering. The contract also
|
||||
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||||
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
|
||||
*/
|
||||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||||
const rules = preset.simulationRules
|
||||
@@ -178,14 +182,21 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<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',
|
||||
'- 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.',
|
||||
'- 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.',
|
||||
'- 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.',
|
||||
@@ -700,39 +711,208 @@ export function resolveNationRef(state: GameState, ref: string): string | 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
|
||||
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
||||
* (the route-level functions do that once per decision). Rename changes only
|
||||
* `Nation.name` — the id, its territory, its relations (keyed by id), and the
|
||||
* player pointer all stay valid, so nothing downstream breaks; a
|
||||
* player-supplied `edits.name` overrides the proposed `to` (an empty edit
|
||||
* falls back to the proposal's own name). An unresolvable or ambiguous
|
||||
* `nationRef` is a silent no-op. Split and merge are declared for P4b and are
|
||||
* a no-op until then — the op stays pending-free once the player resolves it,
|
||||
* but the world does not change yet.
|
||||
* (the route-level functions do that once per decision). It never throws:
|
||||
* any unresolvable reference or malformed op is a silent no-op (the op is
|
||||
* still removed by confirmNationOp — the world simply doesn't change).
|
||||
*
|
||||
* - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
|
||||
* territory, its relations (keyed by id), and the player pointer all stay
|
||||
* valid, so nothing downstream breaks; a player-supplied `edits.name`
|
||||
* overrides the proposed `to` (an empty edit falls back to the proposal's
|
||||
* own name).
|
||||
*
|
||||
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
|
||||
* be at least two named parts, else no-op. Parts are processed in order;
|
||||
* each receives the region keys of its `territory` that (a) the parent
|
||||
* owns and (b) no earlier part claimed — the first part to name a key
|
||||
* wins. Keys of the parent's territory that no part claimed go to part 0
|
||||
* (in the parent's `territory` order), so the parent's territory is fully
|
||||
* partitioned — no key is lost or ever held twice. Each part gets a
|
||||
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
|
||||
* copy of the parent's), plus a copy of the parent's relations; the
|
||||
* parent is then removed. If the parent was the player's nation,
|
||||
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
|
||||
* into range, default 0); `edits.name` renames that same chosen part.
|
||||
*
|
||||
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
|
||||
* resolve, at least two inputs must resolve to DISTINCT nations, and the
|
||||
* survivor must be one of them — else no-op. The survivor keeps its id,
|
||||
* keeps its own stats, gains the union of all inputs' territories
|
||||
* (survivor's keys first, then each other input's unclaimed keys in its
|
||||
* own order), and gains a union of the inputs' relations (on a third
|
||||
* party the survivor's own view wins, else the first input's). The other
|
||||
* inputs are removed (their ids dropped from everyone else's relations
|
||||
* maps), and if the player led a removed input, `playerNationId` moves to
|
||||
* the survivor. The merged name is `edits.name` (if non-empty), else
|
||||
* `op.into`, else the survivor's current name.
|
||||
*/
|
||||
export function applyNationOp(state: GameState, op: NationOp, edits?: { name?: string }): void {
|
||||
if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null) return; // invalid/ambiguous ref: no-op
|
||||
const n = state.nations[id];
|
||||
if (!n) return;
|
||||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||||
if (name) n.name = name;
|
||||
export function applyNationOp(
|
||||
state: GameState,
|
||||
op: NationOp,
|
||||
edits?: { name?: string; playerPart?: number },
|
||||
): void {
|
||||
if (op.kind === 'rename') {
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null) return; // invalid/ambiguous ref: no-op
|
||||
const n = state.nations[id];
|
||||
if (!n) return;
|
||||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||||
if (name) n.name = name;
|
||||
return;
|
||||
}
|
||||
|
||||
if (op.kind === 'split') {
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
|
||||
const parent = state.nations[id];
|
||||
if (!parent) return;
|
||||
const taken = new Set(Object.keys(state.nations));
|
||||
const created: string[] = [];
|
||||
const claimed = new Set<string>();
|
||||
for (const part of op.parts) {
|
||||
const newId = freshNationId(taken);
|
||||
taken.add(newId);
|
||||
const keys = (part.territory ?? [])
|
||||
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
|
||||
for (const k of keys) claimed.add(k);
|
||||
const partNation: Nation = {
|
||||
id: newId,
|
||||
type: parent.type, // a part keeps the parent's form of state
|
||||
name: part.name,
|
||||
stats: { ...NEW_NATION_STATS },
|
||||
territory: keys,
|
||||
relations: { ...parent.relations },
|
||||
};
|
||||
state.nations[newId] = partNation;
|
||||
created.push(newId);
|
||||
}
|
||||
// Keys the parent owned but no part claimed go to part 0 (in the
|
||||
// parent's territory order) — nothing is lost or duplicated.
|
||||
for (const k of parent.territory) {
|
||||
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
|
||||
}
|
||||
// The player chooses which part is its successor (edits.playerPart,
|
||||
// clamped into range; default 0) — only relevant when the parent was
|
||||
// the player's nation; the same part also gets a player-edited name.
|
||||
const partIdx =
|
||||
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
|
||||
if (state.playerNationId === id) state.playerNationId = created[partIdx];
|
||||
const editedName = (edits?.name ?? '').trim();
|
||||
if (editedName) state.nations[created[partIdx]].name = editedName;
|
||||
delete state.nations[id];
|
||||
dropRelationsTo(state, new Set([id]));
|
||||
return;
|
||||
}
|
||||
|
||||
// merge
|
||||
const resolved: string[] = [];
|
||||
for (const ref of op.nationRefs) {
|
||||
const rid = resolveNationRef(state, ref);
|
||||
if (rid === null) return; // any unresolvable input: no-op
|
||||
resolved.push(rid);
|
||||
}
|
||||
const inputIds = new Set(resolved);
|
||||
const survivorId = resolveNationRef(state, op.survivingRef);
|
||||
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
|
||||
const survivor = state.nations[survivorId];
|
||||
if (!survivor) return;
|
||||
// Territory: the union of all inputs — the survivor's keys first (stable
|
||||
// order), then each other input's unclaimed keys in its own order.
|
||||
const territory = [...survivor.territory];
|
||||
const seen = new Set(territory);
|
||||
for (const rid of resolved) {
|
||||
for (const k of state.nations[rid].territory) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
territory.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Relations: the union of all inputs' relations; the first input to name a
|
||||
// third party sets it, then the survivor's own view overwrites per key.
|
||||
const relations: Record<string, RelationStatus> = {};
|
||||
for (const rid of resolved) {
|
||||
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
|
||||
if (other === survivorId) continue; // never relate to oneself
|
||||
if (!(other in relations)) relations[other] = status;
|
||||
}
|
||||
}
|
||||
for (const [other, status] of Object.entries(survivor.relations)) {
|
||||
if (other !== survivorId) relations[other] = status;
|
||||
}
|
||||
const removed = new Set<string>();
|
||||
for (const rid of inputIds) {
|
||||
if (rid === survivorId) continue;
|
||||
delete state.nations[rid];
|
||||
removed.add(rid);
|
||||
}
|
||||
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
|
||||
state.playerNationId = survivorId;
|
||||
}
|
||||
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
|
||||
if (name) survivor.name = name;
|
||||
survivor.territory = territory;
|
||||
survivor.relations = relations;
|
||||
dropRelationsTo(state, removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a pending nation-op proposal by index: apply it (a rename takes
|
||||
* effect, optionally with a player-edited name; split/merge are a no-op until
|
||||
* P4b) 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.
|
||||
* effect, optionally with a player-edited name; a split partitions the
|
||||
* nation's territory into the new parts, the player picking which part is
|
||||
* its successor via `edits.playerPart`; a merge folds the inputs into the
|
||||
* survivor, optionally renamed via `edits.name`) and remove it from
|
||||
* `pendingNationOps`, bumping `version`. Returns `null` for an invalid
|
||||
* index, so the route answers 400 without mutating. Reused by the
|
||||
* /nation-op/confirm route.
|
||||
*/
|
||||
export function confirmNationOp(
|
||||
state: GameState,
|
||||
index: number,
|
||||
edits?: { name?: string },
|
||||
edits?: { name?: string; playerPart?: number },
|
||||
): GameState | null {
|
||||
if (!validPendingOpIndex(state, index)) return null;
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
|
||||
+8
-4
@@ -230,10 +230,14 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
|
||||
res.json({ game: next });
|
||||
});
|
||||
|
||||
// (v0.23, #16 P4a) Resolve a pending nation-op proposal (a rename the LLM
|
||||
// proposed in an earlier turn, waiting in game.pendingNationOps). No LLM call,
|
||||
// no turn advance — same fast save → broadcast → respond order as
|
||||
// /queue/remove. The version bump lets "never go backwards" clients reconcile.
|
||||
// (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
|
||||
// waiting in game.pendingNationOps: a rename takes effect (body.edits.name
|
||||
// may override the proposed name), a split partitions the nation's territory
|
||||
// into the proposed parts (body.edits.playerPart picks which part the player
|
||||
// leads, default 0), and a merge folds its inputs into the survivor
|
||||
// (body.edits.name may override the merged name). No LLM call, no turn
|
||||
// advance — same fast save → broadcast → respond order as /queue/remove.
|
||||
// The version bump lets "never go backwards" clients reconcile.
|
||||
app.post('/api/games/:id/nation-op/confirm', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
|
||||
+16
-4
@@ -27,9 +27,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
expect(loadRegions(root)).toBe(true);
|
||||
});
|
||||
|
||||
it('loads the full manifest: 1,796 unique valid region keys', () => {
|
||||
it('loads the full manifest: 1,798 unique valid region keys', () => {
|
||||
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', () => {
|
||||
@@ -51,6 +51,18 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
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', () => {
|
||||
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
||||
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)', () => {
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasRegionData()).toBe(true);
|
||||
expect(validKeys().size).toBe(1796);
|
||||
expect(validKeys().size).toBe(1798);
|
||||
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||
expect(loadRegions(realRoot)).toBe(true);
|
||||
expect(validKeys().size).toBe(1796);
|
||||
expect(validKeys().size).toBe(1798);
|
||||
});
|
||||
|
||||
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
||||
|
||||
+39
-24
@@ -30,15 +30,14 @@ export interface TerritoryChange {
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, player
|
||||
// confirms" model) -----------------------------------------------------------
|
||||
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||
// proposes, player confirms" model) ------------------------------------------
|
||||
//
|
||||
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
|
||||
// NOT applied by that turn. It is stored as a *pending proposal* in
|
||||
// `GameState.pendingNationOps` and only takes effect when the player confirms
|
||||
// it (optionally editing it) or is dropped when the player rejects it. Rename
|
||||
// is implemented in v0.23 (P4a); split and merge are declared here so the union
|
||||
// is stable for P4b, and are defensively accepted-but-not-applied until then.
|
||||
// it (optionally editing it) or is dropped when the player rejects it. All
|
||||
// three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
|
||||
|
||||
/**
|
||||
* The identity reference a nation is addressed by inside a {@link NationOp}.
|
||||
@@ -63,9 +62,13 @@ export interface RenameOp {
|
||||
}
|
||||
|
||||
/**
|
||||
* A **split** proposal (declared for #16 P4b; **not** applied by the engine yet
|
||||
* — it is accepted into the pending list, but confirming it is a no-op until
|
||||
* P4b lands). Describes the new nations a source nation is divided into.
|
||||
* A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
|
||||
* nation is divided into. On confirm the source is dissolved: each part gets a
|
||||
* fresh id, the region keys it was given (restricted to keys the source
|
||||
* actually owns — first claim wins), fresh baseline stats, and a copy of the
|
||||
* source's relations; any of the source's unclaimed keys go to `parts[0]`.
|
||||
* When the source is the player's own nation the player chooses which part
|
||||
* they lead (the confirm request's `edits.playerPart`, default `parts[0]`).
|
||||
*/
|
||||
export interface SplitOp {
|
||||
kind: 'split';
|
||||
@@ -76,9 +79,13 @@ export interface SplitOp {
|
||||
}
|
||||
|
||||
/**
|
||||
* A **merge** proposal (declared for #16 P4b; **not** applied by the engine yet
|
||||
* — accepted into the pending list, no-op on confirm until P4b). Folds several
|
||||
* nations into a single surviving nation.
|
||||
* A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
|
||||
* surviving nation. On confirm the survivor keeps its id (renamed to `into`,
|
||||
* or to the confirm request's `edits.name`), its territory becomes the union
|
||||
* of all inputs' territories, it keeps its own stats, and it inherits the
|
||||
* union of all inputs' relations (its own winning per third party, else the
|
||||
* first input's); every non-survivor input is removed. If the player's nation
|
||||
* was a non-survivor input the player pointer is re-pointed to the survivor.
|
||||
*/
|
||||
export interface MergeOp {
|
||||
kind: 'merge';
|
||||
@@ -93,8 +100,8 @@ export interface MergeOp {
|
||||
/**
|
||||
* A structural operation on the set of nations that the LLM may propose in a
|
||||
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid
|
||||
* propose→confirm semantics. Rename is implemented (P4a); split/merge are
|
||||
* declared for P4b and are a safe no-op until then.
|
||||
* propose→confirm semantics. All three kinds are implemented: rename (P4a),
|
||||
* and split / merge (P4b, v0.24).
|
||||
*/
|
||||
export type NationOp = RenameOp | SplitOp | MergeOp;
|
||||
|
||||
@@ -125,12 +132,13 @@ export interface TurnResult {
|
||||
*/
|
||||
unowned: string[];
|
||||
/**
|
||||
* (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
|
||||
* are NOT applied by the turn: each is appended to
|
||||
* (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
|
||||
* turn. These are NOT applied by the turn: each is appended to
|
||||
* `GameState.pendingNationOps` as a proposal awaiting the player's
|
||||
* confirm/reject. Rename is the only kind the engine currently applies;
|
||||
* split/merge are declared for P4b and are accepted-but-dropped. Empty on
|
||||
* turns where the model proposes none.
|
||||
* confirm/reject. All three kinds (rename, split, merge) are implemented —
|
||||
* a confirmed rename changes `Nation.name`; a confirmed split dissolves the
|
||||
* source into its parts; a confirmed merge folds its inputs into the
|
||||
* survivor. Empty on turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
}
|
||||
@@ -240,15 +248,22 @@ export interface RemoveQueueItemResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in
|
||||
* `game.pendingNationOps`. A rename takes effect (the player may override the
|
||||
* proposed name via `edits.name`); split/merge proposals are a no-op on
|
||||
* confirm until P4b. No LLM call, no turn advance.
|
||||
* (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
|
||||
* index in `game.pendingNationOps`. A rename takes effect (the player may
|
||||
* override the proposed name via `edits.name`); a split and a merge are fully
|
||||
* applied (v0.24 P4b). No LLM call, no turn advance.
|
||||
*/
|
||||
export interface ConfirmNationOpRequest {
|
||||
index: number;
|
||||
/** Optional player edits applied on confirm (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 {
|
||||
game: GameState;
|
||||
|
||||
+13
-12
@@ -138,18 +138,19 @@ export interface GameState {
|
||||
*/
|
||||
unowned: string[];
|
||||
/**
|
||||
* (v0.23, #16 P4a) Nation-management ops the LLM has proposed but the player
|
||||
* has not yet resolved: a *pending proposal* list, as opposed to `pending`
|
||||
* (the current round's queued player actions). A turn NEVER applies one —
|
||||
* `applyTurnResult` appends the turn's `nationOps` to this list, and each
|
||||
* proposal survives across turns until the player resolves it: confirming
|
||||
* applies it (a rename changes `Nation.name` only — the id, its territory,
|
||||
* its relations, and the player pointer all stay valid) and removes it;
|
||||
* rejecting removes it without applying. Split/merge are declared for P4b
|
||||
* and are a safe no-op on confirm until then. Always `[]` on a fresh game;
|
||||
* rows saved before v0.23 load with this defaulted to `[]` (see server/db.ts).
|
||||
* (The `NationOp` type lives in protocol.ts; the import there is type-only,
|
||||
* so this file still has no runtime dependencies.)
|
||||
* (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
|
||||
* proposed but the player has not yet resolved: a *pending proposal* list,
|
||||
* as opposed to `pending` (the current round's queued player actions). A
|
||||
* turn NEVER applies one — `applyTurnResult` appends the turn's
|
||||
* `nationOps` to this list, and each proposal survives across turns until
|
||||
* the player resolves it: confirming applies it (a rename changes
|
||||
* `Nation.name` only; a split partitions the nation's region-key territory
|
||||
* into the new nations' territories; a merge folds the inputs' territory
|
||||
* into the survivor) and removes it; rejecting removes it without
|
||||
* applying. Always `[]` on a fresh game; rows saved before v0.23 load with
|
||||
* this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
|
||||
* protocol.ts; the import there is type-only, so this file still has no
|
||||
* runtime dependencies.)
|
||||
*/
|
||||
pendingNationOps: NationOp[];
|
||||
}
|
||||
|
||||
+6
-5
@@ -405,11 +405,12 @@ export default function App() {
|
||||
[game, applyState],
|
||||
);
|
||||
|
||||
// Apply a pending nation op the model proposed (confirm, with an optional
|
||||
// rename-name edit). Fast, non-LLM — gated by `opBusy`, not `busy`. The
|
||||
// version-bumped state comes back in the response and the SSE `state` event.
|
||||
// Apply a pending nation op the model proposed (confirm; `edits.name`
|
||||
// overrides a rename/merge name, `edits.playerPart` picks the player's
|
||||
// successor part in a split). Fast, non-LLM — gated by `opBusy`, not `busy`.
|
||||
// The version-bumped state comes back in the response and the SSE `state` event.
|
||||
const runConfirmNationOp = useCallback(
|
||||
async (index: number, edits?: { name?: string }) => {
|
||||
async (index: number, edits?: { name?: string; playerPart?: number }) => {
|
||||
if (!game) return;
|
||||
setOpBusy(true);
|
||||
try {
|
||||
@@ -603,7 +604,7 @@ interface GameScreenProps {
|
||||
onRemoveQueueItem: (index: number) => void;
|
||||
onAdvisor: (p?: string) => void;
|
||||
onJump: () => void;
|
||||
onConfirmNationOp: (index: number, edits?: { name?: string }) => void;
|
||||
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||
onRejectNationOp: (index: number) => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,12 @@ interface Props {
|
||||
onAdvisor: (prompt?: string) => void;
|
||||
/** Commit the whole pending round in a single LLM turn. */
|
||||
onJump: () => void;
|
||||
/** Apply a pending nation op; `edits.name` 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. */
|
||||
onRejectNationOp: (index: number) => void;
|
||||
/**
|
||||
|
||||
@@ -19,8 +19,12 @@ interface Props {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** A confirm/reject request is in flight — disable the per-op controls. */
|
||||
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). */
|
||||
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`)
|
||||
* 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
|
||||
* can tweak it before confirming); split and merge are declared but not yet
|
||||
* applied (P4b) — they can be dismissed but not confirmed.
|
||||
* and lets the player apply ("LLM proposes, player confirms") or drop each one:
|
||||
* a rename shows the proposed name in an editable field; a split lists the
|
||||
* proposed parts (with the number of regions each is assigned) and, when the
|
||||
* split parent is the player's own nation, radio buttons to pick which new
|
||||
* part the player leads; a merge shows the input nations (the survivor
|
||||
* highlighted) and an editable name for the merged state (default `into`).
|
||||
*/
|
||||
export default function NationOpDialog({
|
||||
game,
|
||||
@@ -52,9 +58,24 @@ export default function NationOpDialog({
|
||||
}: Props) {
|
||||
const ops = game.pendingNationOps;
|
||||
const name = (ref: string) => game.nations[ref]?.name ?? ref;
|
||||
// The player's edit to a rename's target name, keyed by pending-op index.
|
||||
// An unset entry means "use the model's proposed name" (`op.to`).
|
||||
// Resolve a nation ref the way the server does (id first, then a unique
|
||||
// case-insensitive name; null when unknown/ambiguous) — UI hints only, the
|
||||
// server stays authoritative.
|
||||
const resolveRef = (ref: string): string | null => {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (game.nations[r]) return r;
|
||||
const matches = Object.values(game.nations).filter(
|
||||
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||
);
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
};
|
||||
// The player's edits, keyed by pending-op index: the rename/merge target
|
||||
// name (an unset entry means "use the model's proposed name"), and which
|
||||
// split part the player succeeds to (preselected 0 — the radio is
|
||||
// "required" and always has a valid answer).
|
||||
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -68,7 +89,8 @@ export default function NationOpDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
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>
|
||||
</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">
|
||||
{ops.map((op, i) => {
|
||||
const isRename = op.kind === 'rename';
|
||||
const isSplit = op.kind === 'split';
|
||||
const isMerge = op.kind === 'merge';
|
||||
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
|
||||
// Split: is the parent the player's own nation? (the successor radio
|
||||
// is offered only in that case — the engine ignores playerPart otherwise)
|
||||
const splitParentIsPlayer =
|
||||
isSplit && resolveRef((op as { nationRef: string }).nationRef) === game.playerNationId;
|
||||
const playerPart = playerParts[i] ?? 0;
|
||||
// Merge: the survivor (highlighted) and whether the proposal is
|
||||
// even valid (2+ distinct inputs; the survivor among them).
|
||||
const survivorId = isMerge ? resolveRef((op as { survivingRef: string }).survivingRef) : null;
|
||||
const inputIds = isMerge
|
||||
? new Set(
|
||||
(op as { nationRefs: string[] }).nationRefs
|
||||
.map(resolveRef)
|
||||
.filter((x): x is string => x !== null),
|
||||
)
|
||||
: new Set<string>();
|
||||
const mergeValid =
|
||||
!isMerge || (inputIds.size >= 2 && (survivorId !== null && inputIds.has(survivorId)));
|
||||
const mergeName = isMerge ? (edits[i] ?? (op as { into: string }).into).trim() : '';
|
||||
|
||||
const confirm = () => {
|
||||
if (isRename) onConfirm(i, finalName ? { name: finalName } : undefined);
|
||||
else if (isMerge) onConfirm(i, mergeName ? { name: mergeName } : undefined);
|
||||
else onConfirm(i, splitParentIsPlayer ? { playerPart } : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${i}-${op.kind}`}
|
||||
@@ -96,6 +145,7 @@ export default function NationOpDialog({
|
||||
{describeOp(op, name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isRename ? (
|
||||
<div className="space-y-2.5">
|
||||
<Input
|
||||
@@ -118,7 +168,72 @@ export default function NationOpDialog({
|
||||
</Button>
|
||||
<Button
|
||||
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}
|
||||
data-testid="nation-op-confirm"
|
||||
>
|
||||
@@ -128,21 +243,57 @@ export default function NationOpDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it.
|
||||
</span>
|
||||
<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" />
|
||||
Dismiss
|
||||
</Button>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
|
||||
const isSurvivor = resolveRef(ref) === survivorId;
|
||||
return (
|
||||
<Badge
|
||||
key={ri}
|
||||
variant={isSurvivor ? 'default' : 'secondary'}
|
||||
className="text-[11px]"
|
||||
data-testid="nation-op-input"
|
||||
>
|
||||
{name(ref)}
|
||||
{isSurvivor ? ' (survivor)' : ''}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Input
|
||||
value={edits[i] ?? (op as { into: string }).into}
|
||||
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||
placeholder="New name…"
|
||||
data-testid="nation-op-name-input"
|
||||
/>
|
||||
{!mergeValid && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
This merge is not valid as proposed — confirming will dismiss it
|
||||
without changing the world.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onReject(i)}
|
||||
disabled={opBusy}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
data-testid="nation-op-reject"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={confirm}
|
||||
disabled={opBusy}
|
||||
data-testid="nation-op-confirm"
|
||||
>
|
||||
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
+7
-4
@@ -62,10 +62,13 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ index }),
|
||||
}),
|
||||
// (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in
|
||||
// `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the
|
||||
// proposed name). Fast, non-LLM — no turn advance. Returns the updated game.
|
||||
confirmNationOp: (id: string, index: number, edits?: { name?: string }) =>
|
||||
// (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
|
||||
// its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
|
||||
// overrides the proposed name), a split partitions the nation's territory
|
||||
// (`edits.playerPart` picks which part the player leads, default 0), and a
|
||||
// merge folds its inputs into the survivor (`edits.name` overrides the
|
||||
// merged name). Fast, non-LLM — no turn advance. Returns the updated game.
|
||||
confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) =>
|
||||
req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),
|
||||
|
||||
Reference in New Issue
Block a user