Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c620a54c | ||
|
|
7eaed42d51 | ||
|
|
14c5d4881d | ||
|
|
458e9be419 | ||
|
|
f72a59bf5e | ||
|
|
6ebe21591e | ||
|
|
9f230606c4 | ||
|
|
e9fa267fd0 |
@@ -52,7 +52,7 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 127 tests)
|
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 151 tests)
|
||||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||||
@@ -67,10 +67,14 @@ history/
|
|||||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3) + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers (incl. the no-man's-land map-presentation contract)
|
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||||
|
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||||
|
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||||
│ ├── 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/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||||
|
│ │ # PORT/LLM_*/DATA_DIR
|
||||||
|
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||||
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
||||||
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
|
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
|
||||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||||
@@ -91,7 +95,8 @@ history/
|
|||||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
||||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
|
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a)
|
||||||
|
├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
|
||||||
├── EventFeed.tsx # scrolling event log (newest first)
|
├── EventFeed.tsx # scrolling event log (newest first)
|
||||||
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
||||||
├── StatBar.tsx
|
├── StatBar.tsx
|
||||||
@@ -105,7 +110,7 @@ history/
|
|||||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||||
| `npm test` | vitest unit tests (`server/` + `scripts/`; 127 tests) |
|
| `npm test` | vitest unit tests (`server/` + `scripts/`; 151 tests) |
|
||||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `npm run lint` | ESLint flat config |
|
||||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||||
@@ -184,6 +189,44 @@ history/
|
|||||||
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
||||||
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
||||||
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
|
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
|
||||||
|
- **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).**
|
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
@@ -209,9 +252,12 @@ history/
|
|||||||
in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
|
in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
|
||||||
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
|
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
|
||||||
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
|
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
|
||||||
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). The stub answers
|
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a)
|
||||||
plain `content` (never tool calls), so it naturally bypasses the tool loop in a single round.
|
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
|
||||||
Unit + e2e assertions rely on these exact values, so keep the stub deterministic.
|
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
|
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
|
||||||
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
|
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
|
||||||
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.24.0 — 2026-10-14 13:41
|
||||||
|
- **Part 4b of #16: nation splits and merges — "LLM proposes, player confirms" is now complete.** P4a let the LLM propose a *rename* (a safe, single-field change). Now the same confirm/reject flow actually applies the two destructive ops: a **split** (one nation dissolves into several) and a **merge** (several nations fold into one). This is the last piece of #16 — all three `NationOp` kinds are now applied on player confirm.
|
||||||
|
- **Split (`server/engine.ts`).** `applyNationOp` now handles `kind: "split"`: it resolves the parent `nationRef` and requires **≥2** named parts (fewer → a safe no-op, the op is still resolved and removed). It partitions the parent's **region-key** territory across the parts **in order** — each part gets the keys of its `territory` that the parent actually owns and that no earlier part claimed (the **first part to name a key wins**, so a key is never held twice); any of the parent's keys no part claimed go to **part 0** (in the parent's territory order), so nothing is orphaned. Each new nation gets a **fresh id** (`freshNationId`: first free of `n1`, `n2`, …, tracked in a running `taken` set so ids never collide within the op), the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 profile — *not* a copy of the parent's, since a small rump state is not the parent's power), a copy of the parent's relations, and the parent's `type`; the parent is then removed and its id dropped from every other nation's relations map (`dropRelationsTo`). If the **parent was the player's nation**, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped into range, default 0) — that same part also receives an `edits.name` override.
|
||||||
|
- **Merge (`server/engine.ts`).** `kind: "merge"`: every input ref and the survivor ref must resolve, at least **two distinct** inputs are required, and the **survivor must be one of the inputs** — any failure is a safe no-op. The survivor keeps its **id** and its **own stats**; it gains the **union of all inputs' territories** (the survivor's keys first, then each other input's unclaimed keys in its own order) and a **union of the inputs' relations** (the survivor's own view of a third party wins, else the first input's); it is renamed to `edits.name` → `into` → its own name (in that precedence). The other inputs are removed and their ids dropped from every remaining nation's relations maps; if the **player led a removed input**, `playerNationId` moves to the survivor.
|
||||||
|
- **Confirm contract extended (`shared/protocol.ts`).** `ConfirmNationOpRequest.edits` is now `{ name?: string; playerPart?: number }` — `name` overrides a rename's target or a merge's merged name; `playerPart` picks which new part the player's nation becomes in a split (default 0). The `SplitOp` / `MergeOp` / `NationOp` doc comments and the `TurnResult.nationOps` doc now describe the *applied* semantics (no longer "a no-op until P4b").
|
||||||
|
- **Prompt (`server/engine.ts`).** The system prompt's JSON contract now shows all three `nationOps` shapes (rename / split / merge), and the HARD CONSTRAINTS gain one line per op explaining when to use it and the "≥2 parts" / "survivor must be an input" rules — so the model knows what it may propose and that each is a player-confirmed PROPOSAL.
|
||||||
|
- **Client (`src/components/NationOpDialog.tsx`).** The dialog now renders all three kinds with a Confirm button (no more "dismiss-only" split/merge rows): a **split** lists each part (name + the number of regions it was assigned) and, when the split's parent is the **player's own nation**, shows a **radio group** to pick which new part the player leads (preselected 0, so the choice is always valid); a **merge** shows the input nations as chips (the **survivor** highlighted) and an **editable name** for the merged state (defaulting to `into`). Rename keeps its editable-name behavior. The new `onConfirm(index, edits?)` signature threads `playerPart` through `ActionPanel` and `App.tsx` (`runConfirmNationOp`) to the API helper.
|
||||||
|
- **Defensive invariants.** All ops are **pure and never throw**: an unresolvable or ambiguous ref, too few parts, or a survivor that isn't an input makes the op a no-op on the world (the proposal is still resolved and removed on confirm, mirroring P4a's ambiguous-ref behavior). **Sole ownership** is preserved by construction (a key lands in at most one new part / one survivor), and `playerNationId` is always re-pointed to a still-existing nation. The **stub is unchanged** — it still never proposes a nation op (`nationOps: []`) — so, like P4a/P4b before it, the new paths are covered by **unit tests only** and the e2e suite (CI, `LLM_STUB=1`) is unaffected.
|
||||||
|
- **Tests (`server/engine.test.ts`).** New `freshNationId`, `applyNationOp (split)`, and `applyNationOp (merge)` suites: the partition (first-wins, leftovers-to-part-0, sole ownership, fresh ids/stats/relations, parent removed, relations cleanup, player-pointer move, clamped `playerPart`, `edits.name` on the chosen part), the merge (union territory, survivor stats, relations union with survivor-wins, removed ids dropped from everyone else, into-name, player-pointer move, and the four no-op guards), plus an updated prompt-contract test asserting all three op shapes. The old "split/merge is a no-op" P4a test is retargeted to a 1-part split. The unit suite grows **151 → 164**.
|
||||||
|
- **Verification.** Typecheck, lint, **164 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no nation-op assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — major nation changes (P4b, the final part of the epic). #16 was already closed (2026-10-12, after P3 shipped); the PR references it for traceability the same way P4a's PR #38 did ("Part of #16" / "Related: #16") — no auto-close.
|
||||||
|
|
||||||
|
## v0.23.0 — 2026-10-14 12:58
|
||||||
|
- **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b.
|
||||||
|
- **New `NationOp` union (`shared/protocol.ts`).** `rename` / `split` / `merge`, discriminated by `kind`. Each names its target via a `nationRef` the LLM may give as either a nation **id** or **name**. `TurnResult.nationOps` is a **required** `NationOp[]` (the model's proposals this turn); the confirm/reject request types (`NationOpConfirmRequest` `{index, edits?{name?}}`, `NationOpRejectRequest` `{index}`, response `{game}`) live here too.
|
||||||
|
- **`GameState.pendingNationOps` (`shared/types.ts`, type-only import of `NationOp`)** — the proposals awaiting the player's decision. A turn **appends** to it (never clears it on commit), so a proposal persists across turns until confirmed or rejected. Legacy SQLite rows and new games default it to `[]` (`server/db.ts`).
|
||||||
|
- **Engine (`server/engine.ts`), all pure.** `resolveNationRef` resolves a `nationRef` id-first, then a case-insensitive unique-name match (returns `null` for unknown/ambiguous — never throws). `validateNationOps` drops invalid ops defensively. `applyTurnResult` keeps existing pending ops and appends the turn's valid ones (a rename-only turn is `isMeaningful`, so it survives the retry/nudge path). `applyNationOp` mutates a clone (rename → `Nation.name` = edited value or the proposed `to`; split/merge are no-ops for now). `confirmNationOp`/`rejectNationOp` splice by index and bump `version` once (invalid index → `null`). The system prompt (both tool-mode and plain-mode) gains the `nationOps` JSON field and the rename-proposal rule.
|
||||||
|
- **Routes + client.** `POST /api/games/:id/nation-op/confirm` and `.../reject` (404 unknown game, 400 bad index), each returning `{ game }` and broadcasting a version-bumped SSE `state` — mirroring the existing queue add/remove routes. The "Your move" panel shows a "Nation proposals (N)" trigger that opens a dialog listing each pending op; a rename has an editable name field plus **Confirm**/**Reject**, and split/merge offer **Dismiss** (they aren't applied yet).
|
||||||
|
- **Stub + tests.** The deterministic stub **never** proposes a nation op (`nationOps: []`), so this path is covered by **unit tests only** (mirroring the P3 no-man's-land approach) — `server/engine.test.ts` gains suites for `resolveNationRef`, `validateNationOps`, `applyNationOp`/confirm/reject (incl. an invalid-index 400 and the version-bump), and the prompt contract. The unit suite grows **127 → 151**; the e2e suite is unaffected.
|
||||||
|
- **Verification.** Typecheck, lint, **151 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) has no nation-op assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — major nation changes (P4a); #16 stays open (P4b: split/merge).
|
||||||
|
|
||||||
## v0.22.0 — 2026-10-14 12:02
|
## v0.22.0 — 2026-10-14 12:02
|
||||||
- **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game).
|
- **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game).
|
||||||
- **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming.
|
- **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.22.0",
|
"version": "0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function makeState(id: string): GameState {
|
|||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,17 @@ describe('openDb', () => {
|
|||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('(v0.23 #16 P4a) a legacy row saved before the pendingNationOps field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { pendingNationOps?: unknown[] }).pendingNationOps; // a pre-v0.23 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.pendingNationOps).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('delete removes a game by id and reports absence otherwise', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export function openDb(filePath: string): Db {
|
|||||||
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||||
if (state.unowned === undefined) state.unowned = [];
|
if (state.unowned === undefined) state.unowned = [];
|
||||||
|
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
||||||
|
// has none awaiting confirm/reject.
|
||||||
|
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||||
return state;
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+586
-5
@@ -1,20 +1,27 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||||
import { makeEvent, type TurnResult } from '../shared/protocol';
|
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
|
||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
|
applyNationOp,
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
|
confirmNationOp,
|
||||||
|
freshNationId,
|
||||||
GET_NEIGHBOURS_TOOL,
|
GET_NEIGHBOURS_TOOL,
|
||||||
isMeaningful,
|
isMeaningful,
|
||||||
makeNeighboursExecutor,
|
makeNeighboursExecutor,
|
||||||
|
NEW_NATION_STATS,
|
||||||
parseTurnResult,
|
parseTurnResult,
|
||||||
playerFrontier,
|
playerFrontier,
|
||||||
queueRoundItem,
|
queueRoundItem,
|
||||||
|
rejectNationOp,
|
||||||
removeRoundItem,
|
removeRoundItem,
|
||||||
|
resolveNationRef,
|
||||||
runTurn,
|
runTurn,
|
||||||
|
validateNationOps,
|
||||||
validateTurnResult,
|
validateTurnResult,
|
||||||
} from './engine';
|
} from './engine';
|
||||||
|
|
||||||
@@ -79,6 +86,7 @@ function baseState(): GameState {
|
|||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +433,559 @@ describe('removeRoundItem', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveNationRef (v0.23 #16 P4a)', () => {
|
||||||
|
it('resolves an exact id first (even if a name matches something else)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(resolveNationRef(state, 'de')).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a unique name case-insensitively', () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(resolveNationRef(state, 'US')).toBe('us');
|
||||||
|
expect(resolveNationRef(state, ' uS ')).toBe('us'); // trimmed, case-insensitive
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an ambiguous name (two nations share it)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.nations.us.name = 'UK'; // now uk and us are both "UK"
|
||||||
|
expect(resolveNationRef(state, 'UK')).toBeNull();
|
||||||
|
// but the exact id still resolves
|
||||||
|
expect(resolveNationRef(state, 'uk')).toBe('uk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown or empty reference (never throws)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
expect(resolveNationRef(state, 'zzz-not-a-nation')).toBeNull();
|
||||||
|
expect(resolveNationRef(state, ' ')).toBeNull();
|
||||||
|
expect(resolveNationRef(state, '')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||||
|
it('parses a valid rename (trimmed, capped) and drops invalid ones', () => {
|
||||||
|
const ops = validateNationOps([
|
||||||
|
{ kind: 'rename', nationRef: ' de ', to: 'Reich' },
|
||||||
|
{ kind: 'rename', nationRef: 'uk', to: '' }, // empty to -> dropped
|
||||||
|
{ kind: 'rename', to: 'no ref' }, // missing nationRef -> dropped
|
||||||
|
{ kind: 'rename', nationRef: 'us' }, // missing to -> dropped
|
||||||
|
{ kind: 'bogus-kind' }, // unknown kind -> ignored
|
||||||
|
'not-an-object',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
expect(ops).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid split (parts with names kept, nameless parts dropped)', () => {
|
||||||
|
const ops = validateNationOps([
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [
|
||||||
|
{ name: 'East', territory: ['DEU', ''] },
|
||||||
|
{ name: ' ' }, // empty name -> part dropped
|
||||||
|
{ name: 'West' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(ops).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East', territory: ['DEU'] }, { name: 'West', territory: [] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// a split with no usable parts is dropped entirely
|
||||||
|
expect(validateNationOps([{ kind: 'split', nationRef: 'de', parts: [{ name: '' }] }])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid merge and drops malformed ones', () => {
|
||||||
|
const ops = validateNationOps([
|
||||||
|
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
||||||
|
{ kind: 'merge', nationRefs: [], survivingRef: 'uk', into: 'X' }, // no refs -> dropped
|
||||||
|
{ kind: 'merge', nationRefs: ['uk'], survivingRef: '', into: 'X' }, // no survivor -> dropped
|
||||||
|
{ kind: 'merge', nationRefs: ['uk'], survivingRef: 'uk' }, // no into -> dropped
|
||||||
|
]);
|
||||||
|
expect(ops).toEqual([
|
||||||
|
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for non-array input and is never thrown by', () => {
|
||||||
|
expect(validateNationOps(undefined)).toEqual([]);
|
||||||
|
expect(validateNationOps('nope')).toEqual([]);
|
||||||
|
expect(validateNationOps({})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateTurnResult carries validated nationOps through (and defaults to [] when absent)', () => {
|
||||||
|
const withOps = validateTurnResult({
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }],
|
||||||
|
});
|
||||||
|
expect(withOps).not.toBeNull();
|
||||||
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||||
|
|
||||||
|
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||||||
|
expect(without).not.toBeNull();
|
||||||
|
expect(without!.nationOps).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||||
|
it('a proposed rename is NOT applied by the turn — it lands in pendingNationOps, name untouched', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const next = applyTurnResult(state, {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [{ nation: 'de', type: 'political', title: 'Reunification', text: 'The nation is renamed.', outcome: 'success' }],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
||||||
|
});
|
||||||
|
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||||
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
|
expect(next.version).toBe(2);
|
||||||
|
// the input is not mutated
|
||||||
|
expect(state.nations.de.name).toBe('DE');
|
||||||
|
expect(state.pendingNationOps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pending proposals SURVIVE a subsequent turn (unlike the round queue) and new ones append', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.pendingNationOps = [{ kind: 'rename', nationRef: 'de', to: 'Reich' }]; // unresolved from turn N-1
|
||||||
|
const next = applyTurnResult(state, {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn
|
||||||
|
});
|
||||||
|
expect(next.pendingNationOps).toEqual([
|
||||||
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||||
|
{ kind: 'rename', nationRef: 'us', to: 'USA' }, // the new one was appended
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a legacy state without the pendingNationOps field accepts proposals (defaulted to [])', () => {
|
||||||
|
const state = baseState();
|
||||||
|
delete (state as { pendingNationOps?: NationOp[] }).pendingNationOps; // simulate a pre-v0.23 row
|
||||||
|
const next = applyTurnResult(state, {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
||||||
|
});
|
||||||
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||||||
|
/**
|
||||||
|
* The pending list: op 0 is referenced by id ('de'); op 1 is referenced by
|
||||||
|
* NAME ('US' — the id is lowercase 'us'), so confirming it exercises the
|
||||||
|
* name-resolution path of resolveNationRef.
|
||||||
|
*/
|
||||||
|
function stateWithPending(): GameState {
|
||||||
|
const s = baseState();
|
||||||
|
s.pendingNationOps = [
|
||||||
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' },
|
||||||
|
{ kind: 'rename', nationRef: 'US', to: 'United States' },
|
||||||
|
];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('confirm applies a rename (name only — id/territory/relations/player pointer untouched) and removes the op, bumping the version', () => {
|
||||||
|
const state = stateWithPending();
|
||||||
|
const next = confirmNationOp(state, 0);
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
expect(next!.nations.de.name).toBe('Reich');
|
||||||
|
expect(next!.nations.de.id).toBe('de'); // id unchanged
|
||||||
|
expect(next!.nations.de.territory).toEqual(['DEU']); // territory unchanged
|
||||||
|
expect(next!.nations.uk.relations.de).toBe('hostile'); // relations (keyed by id) unchanged
|
||||||
|
expect(next!.playerNationId).toBe('uk'); // player pointer unchanged
|
||||||
|
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
||||||
|
expect(next!.version).toBe(2);
|
||||||
|
// the input state is not mutated
|
||||||
|
expect(state.nations.de.name).toBe('DE');
|
||||||
|
expect(state.pendingNationOps).toHaveLength(2);
|
||||||
|
expect(state.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm with an edits.name override renames to the player-edited name', () => {
|
||||||
|
const next = confirmNationOp(stateWithPending(), 0, { name: 'Greater Reich' });
|
||||||
|
expect(next!.nations.de.name).toBe('Greater Reich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm with an empty edits.name falls back to the proposed name', () => {
|
||||||
|
const next = confirmNationOp(stateWithPending(), 0, { name: ' ' });
|
||||||
|
expect(next!.nations.de.name).toBe('Reich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm resolves the target by name (not just id) — case-insensitively', () => {
|
||||||
|
const next = confirmNationOp(stateWithPending(), 1); // nationRef 'US' is a name, not the id 'us'
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
expect(next!.nations.us.name).toBe('United States');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm of a 1-part split is a no-op on the world (fewer than 2 parts) — the op is still removed', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
||||||
|
const next = confirmNationOp(state, 0);
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
expect(next!.nations).toEqual(state.nations); // the world is untouched
|
||||||
|
expect(next!.pendingNationOps).toEqual([]); // ...but the proposal was resolved
|
||||||
|
expect(next!.version).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm with an invalid index returns null and mutates nothing', () => {
|
||||||
|
const state = stateWithPending();
|
||||||
|
expect(confirmNationOp(state, -1)).toBeNull();
|
||||||
|
expect(confirmNationOp(state, 2)).toBeNull();
|
||||||
|
expect(confirmNationOp(state, 0.5)).toBeNull();
|
||||||
|
expect(state.nations.de.name).toBe('DE');
|
||||||
|
expect(state.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reject drops the proposal without applying it, bumping the version', () => {
|
||||||
|
const state = stateWithPending();
|
||||||
|
const next = rejectNationOp(state, 0);
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
expect(next!.nations.de.name).toBe('DE'); // unchanged
|
||||||
|
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
||||||
|
expect(next!.version).toBe(2);
|
||||||
|
expect(state.pendingNationOps).toHaveLength(2); // input untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reject with an invalid index returns null and mutates nothing', () => {
|
||||||
|
const state = stateWithPending();
|
||||||
|
expect(rejectNationOp(state, -1)).toBeNull();
|
||||||
|
expect(rejectNationOp(state, 2)).toBeNull();
|
||||||
|
expect(state.pendingNationOps).toHaveLength(2);
|
||||||
|
expect(state.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ambiguous/invalid nationRef is a no-op on confirm (the op is resolved, the world unchanged)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.nations.us.name = 'UK'; // make "UK" ambiguous (uk and us)
|
||||||
|
state.pendingNationOps = [{ kind: 'rename', nationRef: 'UK', to: 'Empire' }];
|
||||||
|
const next = confirmNationOp(state, 0);
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
expect(next!.nations.uk.name).toBe('UK'); // neither nation was renamed
|
||||||
|
expect(next!.nations.us.name).toBe('UK');
|
||||||
|
expect(next!.pendingNationOps).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
|
||||||
|
it('both modes contract the nationOps field with all three op kinds and the proposal rule', () => {
|
||||||
|
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||||
|
// the JSON shape carries all three kinds in both modes
|
||||||
|
expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
|
||||||
|
expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
|
||||||
|
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
|
||||||
|
// the rule explains that ops are proposals only the player can confirm
|
||||||
|
expect(p).toMatch(/PROPOSAL/);
|
||||||
|
expect(p).toMatch(/only take effect if the player confirms/);
|
||||||
|
// and the split/merge shapes are spelt out in the hard constraints
|
||||||
|
expect(p).toMatch(/at least 2 parts/);
|
||||||
|
expect(p).toMatch(/survivingRef" must name one of them/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freshNationId (v0.24 #16 P4b)', () => {
|
||||||
|
it('returns the first free of n1, n2, ... against the taken set', () => {
|
||||||
|
expect(freshNationId(new Set())).toBe('n1');
|
||||||
|
expect(freshNationId(new Set(['n1']))).toBe('n2');
|
||||||
|
expect(freshNationId(new Set(['n1', 'n2', 'n3']))).toBe('n4');
|
||||||
|
// non-n ids never collide: existing nation ids are irrelevant
|
||||||
|
expect(freshNationId(new Set(['uk', 'de', 'us']))).toBe('n1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyNationOp (split, v0.24 #16 P4b)', () => {
|
||||||
|
/** de owns four regions; the player is uk (so a split of de does not move the pointer). */
|
||||||
|
function splitState(): GameState {
|
||||||
|
const s = baseState();
|
||||||
|
s.nations.de.territory = ['DEU-N', 'DEU-S', 'DEU-E', 'DEU-W'];
|
||||||
|
s.nations.de.relations = { uk: 'ally' };
|
||||||
|
s.nations.uk.relations.de = 'hostile'; // mirror entry, to check the cleanup
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('partitions the parent territory: first-wins per key, leftovers to part 0, fresh ids, baseline stats, copied relations, parent removed', () => {
|
||||||
|
const state = splitState();
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [
|
||||||
|
{ name: 'East', territory: ['DEU-E', 'DEU-W', 'ZZZ-not-a-key'] },
|
||||||
|
{ name: 'West', territory: ['DEU-W', 'DEU-S'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// the parent is gone and two fresh nations exist (n1, n2 — uk/us/de were taken)
|
||||||
|
expect(state.nations.de).toBeUndefined();
|
||||||
|
expect(Object.keys(state.nations).sort()).toEqual(['n1', 'n2', 'uk', 'us']);
|
||||||
|
const east = state.nations.n1;
|
||||||
|
const west = state.nations.n2;
|
||||||
|
// East claimed DEU-E and DEU-W first (the invalid key is ignored); the
|
||||||
|
// only key no part claimed — DEU-N (DEU-S went to West) — goes to part 0
|
||||||
|
// in the parent's territory order.
|
||||||
|
expect(east.territory).toEqual(['DEU-E', 'DEU-W', 'DEU-N']);
|
||||||
|
// West's DEU-W was already claimed by East; it keeps only DEU-S.
|
||||||
|
expect(west.territory).toEqual(['DEU-S']);
|
||||||
|
// every parent key sits in exactly one part (sole ownership, nothing lost)
|
||||||
|
const all = [...east.territory, ...west.territory].sort();
|
||||||
|
expect(all).toEqual(['DEU-E', 'DEU-N', 'DEU-S', 'DEU-W']);
|
||||||
|
// fresh baseline stats (not the parent's) and inherited form of state
|
||||||
|
expect(east.stats).toEqual(NEW_NATION_STATS);
|
||||||
|
expect(west.stats).toEqual(NEW_NATION_STATS);
|
||||||
|
expect(east.type).toBe('major');
|
||||||
|
expect(west.type).toBe('major');
|
||||||
|
// both parts copied the parent's relations
|
||||||
|
expect(east.relations).toEqual({ uk: 'ally' });
|
||||||
|
expect(west.relations).toEqual({ uk: 'ally' });
|
||||||
|
// the mirror entry for the removed parent is cleaned up
|
||||||
|
expect(state.nations.uk.relations.de).toBeUndefined();
|
||||||
|
expect(state.nations.uk.relations.us).toBe('neutral'); // other entries untouched
|
||||||
|
// the player (uk) is not affected
|
||||||
|
expect(state.playerNationId).toBe('uk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unresolvable nationRef is a no-op (the world is untouched, never throws)', () => {
|
||||||
|
const state = splitState();
|
||||||
|
const before = JSON.parse(JSON.stringify(state));
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'zzz-not-a-nation',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }],
|
||||||
|
});
|
||||||
|
expect(state).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a split named by the parent NAME works (ref resolution, not just id)', () => {
|
||||||
|
const state = splitState();
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'DE', // the name; the id is 'de'
|
||||||
|
parts: [{ name: 'East', territory: ['DEU-E'] }, { name: 'West', territory: ['DEU-W'] }],
|
||||||
|
});
|
||||||
|
expect(state.nations.de).toBeUndefined();
|
||||||
|
expect(state.nations.n1.name).toBe('East');
|
||||||
|
expect(state.nations.n2.name).toBe('West');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when the parent is the player nation, playerNationId moves to the chosen part (edits.playerPart: default 0, clamped in range)', () => {
|
||||||
|
const state = splitState();
|
||||||
|
state.playerNationId = 'de';
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
||||||
|
});
|
||||||
|
expect(state.playerNationId).toBe('n1'); // default: the first part
|
||||||
|
|
||||||
|
const state2 = splitState();
|
||||||
|
state2.playerNationId = 'de';
|
||||||
|
applyNationOp(
|
||||||
|
state2,
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
||||||
|
},
|
||||||
|
{ playerPart: 1 },
|
||||||
|
);
|
||||||
|
expect(state2.playerNationId).toBe('n2');
|
||||||
|
|
||||||
|
const state3 = splitState();
|
||||||
|
state3.playerNationId = 'de';
|
||||||
|
applyNationOp(
|
||||||
|
state3,
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }],
|
||||||
|
},
|
||||||
|
{ playerPart: 99 }, // clamped down to the last part
|
||||||
|
);
|
||||||
|
expect(state3.playerNationId).toBe('n2');
|
||||||
|
|
||||||
|
const state4 = splitState();
|
||||||
|
state4.playerNationId = 'de';
|
||||||
|
applyNationOp(
|
||||||
|
state4,
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }],
|
||||||
|
},
|
||||||
|
{ playerPart: -5 }, // clamped up to the first part
|
||||||
|
);
|
||||||
|
expect(state4.playerNationId).toBe('n1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits.name renames the chosen part (edits.playerPart picks which one)', () => {
|
||||||
|
const state = splitState();
|
||||||
|
applyNationOp(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }],
|
||||||
|
},
|
||||||
|
{ playerPart: 1, name: 'New West' },
|
||||||
|
);
|
||||||
|
expect(state.nations.n1.name).toBe('East'); // untouched part keeps its name
|
||||||
|
expect(state.nations.n2.name).toBe('New West');
|
||||||
|
|
||||||
|
const state2 = splitState();
|
||||||
|
applyNationOp(
|
||||||
|
state2,
|
||||||
|
{
|
||||||
|
kind: 'split',
|
||||||
|
nationRef: 'de',
|
||||||
|
parts: [{ name: 'East' }, { name: 'West' }],
|
||||||
|
},
|
||||||
|
{ name: 'First of the Remnant' }, // no playerPart -> the first part
|
||||||
|
);
|
||||||
|
expect(state2.nations.n1.name).toBe('First of the Remnant');
|
||||||
|
expect(state2.nations.n2.name).toBe('West');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a 1-part split is a no-op on the world (fewer than 2 parts)', () => {
|
||||||
|
const state = splitState();
|
||||||
|
const before = JSON.parse(JSON.stringify(state));
|
||||||
|
applyNationOp(state, { kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] });
|
||||||
|
expect(state).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyNationOp (merge, v0.24 #16 P4b)', () => {
|
||||||
|
/**
|
||||||
|
* de and us will be merged (the survivor is us — deliberately listed
|
||||||
|
* second in nationRefs, so the "survivor's view wins" rule can be observed
|
||||||
|
* overwriting the first input's views). uk, fr and ja survive as third
|
||||||
|
* parties; the survivor has no view on ja, so ja must come from the first
|
||||||
|
* input's map.
|
||||||
|
*/
|
||||||
|
function mergeState(): GameState {
|
||||||
|
const s = baseState();
|
||||||
|
s.nations.de.relations = { us: 'rival', uk: 'hostile', fr: 'rival', ja: 'rival' };
|
||||||
|
s.nations.us.relations = { de: 'ally', uk: 'ally', fr: 'neutral' }; // no view on ja
|
||||||
|
s.nations.uk.relations = { us: 'ally', de: 'hostile', fr: 'neutral', ja: 'neutral' };
|
||||||
|
s.nations.fr = {
|
||||||
|
id: 'fr',
|
||||||
|
type: 'major',
|
||||||
|
name: 'FR',
|
||||||
|
stats: { military: 40, economy: 60, stability: 70, population: 60 },
|
||||||
|
territory: ['FRA'],
|
||||||
|
relations: { us: 'neutral', de: 'rival', uk: 'neutral', ja: 'neutral' },
|
||||||
|
};
|
||||||
|
s.nations.ja = {
|
||||||
|
id: 'ja',
|
||||||
|
type: 'major',
|
||||||
|
name: 'JA',
|
||||||
|
stats: { military: 30, economy: 80, stability: 50, population: 90 },
|
||||||
|
territory: ['JPN'],
|
||||||
|
relations: { us: 'neutral', de: 'rival', uk: 'neutral', fr: 'neutral' },
|
||||||
|
};
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('folds the inputs into the survivor: union territory (survivor first), survivor stats, survivor view wins per third party (else first input), non-input third parties kept, removed ids dropped from everyone else, into-name', () => {
|
||||||
|
const state = mergeState();
|
||||||
|
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
||||||
|
// the removed input is gone; the survivor and all non-inputs survive
|
||||||
|
expect(state.nations.de).toBeUndefined();
|
||||||
|
expect(Object.keys(state.nations).sort()).toEqual(['fr', 'ja', 'uk', 'us']);
|
||||||
|
// the survivor keeps its id, its own stats, and the merged name
|
||||||
|
expect(state.nations.us.id).toBe('us');
|
||||||
|
expect(state.nations.us.stats).toEqual({ military: 60, economy: 90, stability: 80, population: 80 });
|
||||||
|
expect(state.nations.us.name).toBe('Union');
|
||||||
|
// territory: the union — the survivor's keys first, then the other input's
|
||||||
|
expect(state.nations.us.territory).toEqual(['USA', 'DEU']);
|
||||||
|
// relations: on uk and fr the survivor's own views (ally/neutral) beat the
|
||||||
|
// first input's (hostile/rival); ja has no survivor view, so the first
|
||||||
|
// input's (rival) stands; the removed input's id is gone from the map.
|
||||||
|
expect(state.nations.us.relations).toEqual({ uk: 'ally', fr: 'neutral', ja: 'rival' });
|
||||||
|
// the removed id is dropped from every remaining nation's relations map
|
||||||
|
expect(state.nations.uk.relations).toEqual({ us: 'ally', fr: 'neutral', ja: 'neutral' });
|
||||||
|
expect(state.nations.fr.relations).toEqual({ us: 'neutral', uk: 'neutral', ja: 'neutral' });
|
||||||
|
expect(state.nations.ja.relations).toEqual({ us: 'neutral', uk: 'neutral', fr: 'neutral' });
|
||||||
|
// the player (uk) is not part of the merge — the pointer stays valid
|
||||||
|
expect(state.playerNationId).toBe('uk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the player pointer moves to the survivor when the player led a removed input', () => {
|
||||||
|
const state = mergeState();
|
||||||
|
state.playerNationId = 'de'; // the player's nation is absorbed
|
||||||
|
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
||||||
|
expect(state.playerNationId).toBe('us');
|
||||||
|
expect(state.nations[state.playerNationId].name).toBe('Union');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits.name overrides the merged name; an empty edit falls back to "into", then to the survivor name', () => {
|
||||||
|
const state = mergeState();
|
||||||
|
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: 'United World' });
|
||||||
|
expect(state.nations.us.name).toBe('United World');
|
||||||
|
|
||||||
|
const state2 = mergeState();
|
||||||
|
applyNationOp(state2, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: ' ' });
|
||||||
|
expect(state2.nations.us.name).toBe('Union');
|
||||||
|
|
||||||
|
const state3 = mergeState();
|
||||||
|
applyNationOp(state3, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: '' }); // empty into: keep the survivor's name
|
||||||
|
expect(state3.nations.us.name).toBe('US');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unresolvable input ref is a no-op (never throws, world untouched)', () => {
|
||||||
|
const state = mergeState();
|
||||||
|
const before = JSON.parse(JSON.stringify(state));
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'merge',
|
||||||
|
nationRefs: ['us', 'zzz-not-a-nation'],
|
||||||
|
survivingRef: 'uk',
|
||||||
|
into: 'X',
|
||||||
|
});
|
||||||
|
expect(state).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a survivor that is not one of the inputs is a no-op', () => {
|
||||||
|
const state = mergeState();
|
||||||
|
const before = JSON.parse(JSON.stringify(state));
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'merge',
|
||||||
|
nationRefs: ['us', 'de'],
|
||||||
|
survivingRef: 'uk', // uk is not an input
|
||||||
|
into: 'X',
|
||||||
|
});
|
||||||
|
expect(state).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fewer than two DISTINCT inputs is a no-op (the same nation named twice)', () => {
|
||||||
|
const state = mergeState();
|
||||||
|
const before = JSON.parse(JSON.stringify(state));
|
||||||
|
applyNationOp(state, {
|
||||||
|
kind: 'merge',
|
||||||
|
nationRefs: ['us', 'US'], // both resolve to 'us'
|
||||||
|
survivingRef: 'us',
|
||||||
|
into: 'X',
|
||||||
|
});
|
||||||
|
expect(state).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseTurnResult', () => {
|
describe('parseTurnResult', () => {
|
||||||
it('parses a fenced JSON block', () => {
|
it('parses a fenced JSON block', () => {
|
||||||
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||||||
@@ -478,6 +1039,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -493,6 +1055,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -514,6 +1077,7 @@ describe('applyTurnResult', () => {
|
|||||||
// is not a real key at all. Both are proposed as a gain by DE.
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||||
@@ -538,6 +1102,7 @@ describe('applyTurnResult', () => {
|
|||||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
@@ -554,6 +1119,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|||||||
stateChanges: [],
|
stateChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||||
@@ -642,6 +1208,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||||
@@ -756,7 +1323,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
|
|
||||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||||
expect(
|
expect(
|
||||||
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'] }),
|
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1063,8 +1630,22 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
|||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
it('is true with an event, false when everything is empty', () => {
|
it('is true with an event, false when everything is empty', () => {
|
||||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(false);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||||
|
expect(
|
||||||
|
isMeaningful({
|
||||||
|
date: 'x',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+352
-8
@@ -7,6 +7,8 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
Nation,
|
||||||
|
NationStats,
|
||||||
Preset,
|
Preset,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
RoundItem,
|
RoundItem,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
isDateLike,
|
isDateLike,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
nationForPrompt,
|
nationForPrompt,
|
||||||
|
type NationOp,
|
||||||
type TurnEventSpec,
|
type TurnEventSpec,
|
||||||
type TurnResult,
|
type TurnResult,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
@@ -133,7 +136,9 @@ export function playerFrontier(
|
|||||||
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
||||||
* available) the contract gains the contiguity rules for territory transfers
|
* available) the contract gains the contiguity rules for territory transfers
|
||||||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||||||
* may verify borders with the tool before answering.
|
* may verify borders with the tool before answering. The contract also
|
||||||
|
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||||||
|
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
|
||||||
*/
|
*/
|
||||||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||||||
const rules = preset.simulationRules
|
const rules = preset.simulationRules
|
||||||
@@ -176,13 +181,22 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
||||||
' "unowned": ["<region key>"]',
|
' "unowned": ["<region key>"],',
|
||||||
|
' "nationOps": [',
|
||||||
|
' {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},',
|
||||||
|
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||||
|
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||||
|
' ],',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and region keys that exist in the provided state.',
|
'- Use only nation ids and region keys that exist in the provided state.',
|
||||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||||
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
|
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
|
||||||
|
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
|
||||||
|
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||||
|
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||||
|
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -316,6 +330,53 @@ function str(s: unknown, max = 0): string | null {
|
|||||||
return max ? t.slice(0, max) : t;
|
return max ? t.slice(0, max) : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
|
||||||
|
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
|
||||||
|
* unknown `kind` or missing/empty required fields are dropped, never throw.
|
||||||
|
* Rename: non-empty `nationRef` + `to` (each capped at 200 chars — resolution
|
||||||
|
* of `nationRef` to a concrete nation happens later, at confirm time). Split:
|
||||||
|
* non-empty `nationRef` + at least one part with a name. Merge: non-empty
|
||||||
|
* `nationRefs` (capped at 20) + `survivingRef` + `into`.
|
||||||
|
*/
|
||||||
|
export function validateNationOps(raw: unknown): NationOp[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: NationOp[] = [];
|
||||||
|
for (const e of raw) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const o = e as Record<string, unknown>;
|
||||||
|
if (o.kind === 'rename') {
|
||||||
|
const nationRef = str(o.nationRef, 200);
|
||||||
|
const to = str(o.to, 200);
|
||||||
|
if (nationRef && to) out.push({ kind: 'rename', nationRef, to });
|
||||||
|
} else if (o.kind === 'split') {
|
||||||
|
const nationRef = str(o.nationRef, 200);
|
||||||
|
const parts = (Array.isArray(o.parts) ? o.parts : [])
|
||||||
|
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
|
||||||
|
.map((p) => {
|
||||||
|
const name = str(p.name, 200);
|
||||||
|
const territory = Array.isArray(p.territory)
|
||||||
|
? p.territory.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
|
||||||
|
: [];
|
||||||
|
return { name, territory };
|
||||||
|
})
|
||||||
|
.filter((p): p is { name: string; territory: string[] } => p.name !== null);
|
||||||
|
if (nationRef && parts.length > 0) out.push({ kind: 'split', nationRef, parts });
|
||||||
|
} else if (o.kind === 'merge') {
|
||||||
|
const nationRefs = (Array.isArray(o.nationRefs) ? o.nationRefs : [])
|
||||||
|
.map((x) => (typeof x === 'string' ? x.trim().slice(0, 200) : ''))
|
||||||
|
.filter((x) => x !== '')
|
||||||
|
.slice(0, 20);
|
||||||
|
const survivingRef = str(o.survivingRef, 200);
|
||||||
|
const into = str(o.into, 200);
|
||||||
|
if (nationRefs.length > 0 && survivingRef && into) {
|
||||||
|
out.push({ kind: 'merge', nationRefs, survivingRef, into });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||||||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||||||
@@ -331,6 +392,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(o.events)) {
|
if (Array.isArray(o.events)) {
|
||||||
@@ -415,17 +477,29 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
|
||||||
|
// are NOT applied by the turn — applyTurnResult appends them to the pending
|
||||||
|
// list for the player to confirm/reject.
|
||||||
|
result.nationOps = validateNationOps(o.nationOps);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A turn is "meaningful" if it produced at least an event, a stat change, or
|
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||||
* a no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||||
* when the model under-narrates it, so it must survive the retry/nudge path
|
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
|
||||||
* instead of being replaced by the generic "unclear" result).
|
* a proposal is a real pending-world change the player will resolve, so it
|
||||||
|
* must survive the retry/nudge path instead of being replaced by the generic
|
||||||
|
* "unclear" result).
|
||||||
*/
|
*/
|
||||||
export function isMeaningful(result: TurnResult): boolean {
|
export function isMeaningful(result: TurnResult): boolean {
|
||||||
return result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0;
|
return (
|
||||||
|
result.events.length > 0 ||
|
||||||
|
result.stateChanges.length > 0 ||
|
||||||
|
result.unowned.length > 0 ||
|
||||||
|
result.nationOps.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Applying the delta -------------------------------------------------------
|
// --- Applying the delta -------------------------------------------------------
|
||||||
@@ -443,7 +517,10 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
/**
|
/**
|
||||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||||
* events, apply stat changes (clamped), symmetric relations, and territory
|
* events, apply stat changes (clamped), symmetric relations, and territory
|
||||||
* moves (including no-man's-land releases, v0.22 #16 P3).
|
* moves (including no-man's-land releases, v0.22 #16 P3). It also appends any
|
||||||
|
* proposed nation-ops to the pending list (v0.23 #16 P4a) without applying
|
||||||
|
* them — see the doc comment on `pendingNationOps` for why that list
|
||||||
|
* deliberately survives across turns.
|
||||||
*/
|
*/
|
||||||
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
@@ -559,6 +636,13 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
|||||||
}
|
}
|
||||||
|
|
||||||
next.unowned = [...unowned];
|
next.unowned = [...unowned];
|
||||||
|
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
|
||||||
|
// are appended to the pending list, which (unlike `pending`) SURVIVES
|
||||||
|
// across turns until the player confirms or rejects each one. `next` is a
|
||||||
|
// deep clone of `state`, so `next.pendingNationOps` is a copy of whatever
|
||||||
|
// the state already held (or undefined for a legacy row) — start from that
|
||||||
|
// and append the new proposals, defaulting a missing field to `[]`.
|
||||||
|
next.pendingNationOps = [...(next.pendingNationOps ?? []), ...result.nationOps];
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,6 +677,265 @@ export function removeRoundItem(state: GameState, index: number | undefined): Ga
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes,
|
||||||
|
// player confirms" model) ----------------------------------------------------
|
||||||
|
//
|
||||||
|
// A NationOp proposed by a turn lands in `GameState.pendingNationOps` (see
|
||||||
|
// applyTurnResult) and waits there across turns. These functions resolve the
|
||||||
|
// player's decision on one pending proposal, by index. Like removeRoundItem
|
||||||
|
// they are pure: clone the state, mutate the clone, bump `version` once,
|
||||||
|
// return the new state — and return `null` for a bad index so the route can
|
||||||
|
// answer 400 without mutating anything.
|
||||||
|
|
||||||
|
/** True when `index` is a valid, in-bounds integer position in the pending list. */
|
||||||
|
function validPendingOpIndex(state: GameState, index: number): boolean {
|
||||||
|
const len = state.pendingNationOps?.length ?? 0;
|
||||||
|
return Number.isInteger(index) && index >= 0 && index < len;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
|
||||||
|
* write either the nation's stable `id` or its human-readable `name`) to a
|
||||||
|
* concrete nation id: an exact id match wins, then a unique case-insensitive
|
||||||
|
* name match. Returns `null` when the reference is empty, unknown, or
|
||||||
|
* ambiguous (two or more nations share that name) — every caller treats
|
||||||
|
* `null` as "no-op" and nothing here ever throws.
|
||||||
|
*/
|
||||||
|
export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||||
|
const r = (ref ?? '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
if (state.nations[r]) return r; // exact id first
|
||||||
|
const matches = Object.values(state.nations).filter(
|
||||||
|
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||||
|
);
|
||||||
|
return matches.length === 1 ? matches[0].id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh, unused nation id for a newly created nation (split
|
||||||
|
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||||
|
* each id it creates to `taken` as it goes, so ids stay unique within an
|
||||||
|
* op and are never reused from a removed nation.
|
||||||
|
*/
|
||||||
|
export function freshNationId(taken: ReadonlySet<string>): string {
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
const id = `n${i}`;
|
||||||
|
if (!taken.has(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline stats a freshly created nation starts with (split parts,
|
||||||
|
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||||
|
* a copy of the parent's stats (a small rump state is not the parent's
|
||||||
|
* military power).
|
||||||
|
*/
|
||||||
|
export const NEW_NATION_STATS: NationStats = {
|
||||||
|
military: 50,
|
||||||
|
economy: 50,
|
||||||
|
stability: 50,
|
||||||
|
population: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
|
||||||
|
* the removed nations' ids from every remaining nation's `relations` map so
|
||||||
|
* no key dangles. (The client already degrades gracefully on a missing
|
||||||
|
* target, but consistent data is cleaner.)
|
||||||
|
*/
|
||||||
|
function dropRelationsTo(state: GameState, removedIds: ReadonlySet<string>): void {
|
||||||
|
for (const n of Object.values(state.nations)) {
|
||||||
|
for (const id of removedIds) {
|
||||||
|
if (id in n.relations) delete n.relations[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one NationOp to a state, MUTATING the passed object in place (the
|
||||||
|
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
||||||
|
* (the route-level functions do that once per decision). It never throws:
|
||||||
|
* any unresolvable reference or malformed op is a silent no-op (the op is
|
||||||
|
* still removed by confirmNationOp — the world simply doesn't change).
|
||||||
|
*
|
||||||
|
* - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
|
||||||
|
* territory, its relations (keyed by id), and the player pointer all stay
|
||||||
|
* valid, so nothing downstream breaks; a player-supplied `edits.name`
|
||||||
|
* overrides the proposed `to` (an empty edit falls back to the proposal's
|
||||||
|
* own name).
|
||||||
|
*
|
||||||
|
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
|
||||||
|
* be at least two named parts, else no-op. Parts are processed in order;
|
||||||
|
* each receives the region keys of its `territory` that (a) the parent
|
||||||
|
* owns and (b) no earlier part claimed — the first part to name a key
|
||||||
|
* wins. Keys of the parent's territory that no part claimed go to part 0
|
||||||
|
* (in the parent's `territory` order), so the parent's territory is fully
|
||||||
|
* partitioned — no key is lost or ever held twice. Each part gets a
|
||||||
|
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
|
||||||
|
* copy of the parent's), plus a copy of the parent's relations; the
|
||||||
|
* parent is then removed. If the parent was the player's nation,
|
||||||
|
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
|
||||||
|
* into range, default 0); `edits.name` renames that same chosen part.
|
||||||
|
*
|
||||||
|
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
|
||||||
|
* resolve, at least two inputs must resolve to DISTINCT nations, and the
|
||||||
|
* survivor must be one of them — else no-op. The survivor keeps its id,
|
||||||
|
* keeps its own stats, gains the union of all inputs' territories
|
||||||
|
* (survivor's keys first, then each other input's unclaimed keys in its
|
||||||
|
* own order), and gains a union of the inputs' relations (on a third
|
||||||
|
* party the survivor's own view wins, else the first input's). The other
|
||||||
|
* inputs are removed (their ids dropped from everyone else's relations
|
||||||
|
* maps), and if the player led a removed input, `playerNationId` moves to
|
||||||
|
* the survivor. The merged name is `edits.name` (if non-empty), else
|
||||||
|
* `op.into`, else the survivor's current name.
|
||||||
|
*/
|
||||||
|
export function applyNationOp(
|
||||||
|
state: GameState,
|
||||||
|
op: NationOp,
|
||||||
|
edits?: { name?: string; playerPart?: number },
|
||||||
|
): void {
|
||||||
|
if (op.kind === 'rename') {
|
||||||
|
const id = resolveNationRef(state, op.nationRef);
|
||||||
|
if (id === null) return; // invalid/ambiguous ref: no-op
|
||||||
|
const n = state.nations[id];
|
||||||
|
if (!n) return;
|
||||||
|
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||||||
|
if (name) n.name = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.kind === 'split') {
|
||||||
|
const id = resolveNationRef(state, op.nationRef);
|
||||||
|
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
|
||||||
|
const parent = state.nations[id];
|
||||||
|
if (!parent) return;
|
||||||
|
const taken = new Set(Object.keys(state.nations));
|
||||||
|
const created: string[] = [];
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
for (const part of op.parts) {
|
||||||
|
const newId = freshNationId(taken);
|
||||||
|
taken.add(newId);
|
||||||
|
const keys = (part.territory ?? [])
|
||||||
|
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
|
||||||
|
for (const k of keys) claimed.add(k);
|
||||||
|
const partNation: Nation = {
|
||||||
|
id: newId,
|
||||||
|
type: parent.type, // a part keeps the parent's form of state
|
||||||
|
name: part.name,
|
||||||
|
stats: { ...NEW_NATION_STATS },
|
||||||
|
territory: keys,
|
||||||
|
relations: { ...parent.relations },
|
||||||
|
};
|
||||||
|
state.nations[newId] = partNation;
|
||||||
|
created.push(newId);
|
||||||
|
}
|
||||||
|
// Keys the parent owned but no part claimed go to part 0 (in the
|
||||||
|
// parent's territory order) — nothing is lost or duplicated.
|
||||||
|
for (const k of parent.territory) {
|
||||||
|
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
|
||||||
|
}
|
||||||
|
// The player chooses which part is its successor (edits.playerPart,
|
||||||
|
// clamped into range; default 0) — only relevant when the parent was
|
||||||
|
// the player's nation; the same part also gets a player-edited name.
|
||||||
|
const partIdx =
|
||||||
|
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
|
||||||
|
if (state.playerNationId === id) state.playerNationId = created[partIdx];
|
||||||
|
const editedName = (edits?.name ?? '').trim();
|
||||||
|
if (editedName) state.nations[created[partIdx]].name = editedName;
|
||||||
|
delete state.nations[id];
|
||||||
|
dropRelationsTo(state, new Set([id]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge
|
||||||
|
const resolved: string[] = [];
|
||||||
|
for (const ref of op.nationRefs) {
|
||||||
|
const rid = resolveNationRef(state, ref);
|
||||||
|
if (rid === null) return; // any unresolvable input: no-op
|
||||||
|
resolved.push(rid);
|
||||||
|
}
|
||||||
|
const inputIds = new Set(resolved);
|
||||||
|
const survivorId = resolveNationRef(state, op.survivingRef);
|
||||||
|
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
|
||||||
|
const survivor = state.nations[survivorId];
|
||||||
|
if (!survivor) return;
|
||||||
|
// Territory: the union of all inputs — the survivor's keys first (stable
|
||||||
|
// order), then each other input's unclaimed keys in its own order.
|
||||||
|
const territory = [...survivor.territory];
|
||||||
|
const seen = new Set(territory);
|
||||||
|
for (const rid of resolved) {
|
||||||
|
for (const k of state.nations[rid].territory) {
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
territory.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Relations: the union of all inputs' relations; the first input to name a
|
||||||
|
// third party sets it, then the survivor's own view overwrites per key.
|
||||||
|
const relations: Record<string, RelationStatus> = {};
|
||||||
|
for (const rid of resolved) {
|
||||||
|
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
|
||||||
|
if (other === survivorId) continue; // never relate to oneself
|
||||||
|
if (!(other in relations)) relations[other] = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [other, status] of Object.entries(survivor.relations)) {
|
||||||
|
if (other !== survivorId) relations[other] = status;
|
||||||
|
}
|
||||||
|
const removed = new Set<string>();
|
||||||
|
for (const rid of inputIds) {
|
||||||
|
if (rid === survivorId) continue;
|
||||||
|
delete state.nations[rid];
|
||||||
|
removed.add(rid);
|
||||||
|
}
|
||||||
|
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
|
||||||
|
state.playerNationId = survivorId;
|
||||||
|
}
|
||||||
|
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
|
||||||
|
if (name) survivor.name = name;
|
||||||
|
survivor.territory = territory;
|
||||||
|
survivor.relations = relations;
|
||||||
|
dropRelationsTo(state, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a pending nation-op proposal by index: apply it (a rename takes
|
||||||
|
* effect, optionally with a player-edited name; a split partitions the
|
||||||
|
* nation's territory into the new parts, the player picking which part is
|
||||||
|
* its successor via `edits.playerPart`; a merge folds the inputs into the
|
||||||
|
* survivor, optionally renamed via `edits.name`) and remove it from
|
||||||
|
* `pendingNationOps`, bumping `version`. Returns `null` for an invalid
|
||||||
|
* index, so the route answers 400 without mutating. Reused by the
|
||||||
|
* /nation-op/confirm route.
|
||||||
|
*/
|
||||||
|
export function confirmNationOp(
|
||||||
|
state: GameState,
|
||||||
|
index: number,
|
||||||
|
edits?: { name?: string; playerPart?: number },
|
||||||
|
): GameState | null {
|
||||||
|
if (!validPendingOpIndex(state, index)) return null;
|
||||||
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
|
next.version = (state.version ?? 0) + 1;
|
||||||
|
const op = next.pendingNationOps[index];
|
||||||
|
applyNationOp(next, op, edits);
|
||||||
|
next.pendingNationOps.splice(index, 1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject (drop) a pending nation-op proposal by index without applying it,
|
||||||
|
* bumping `version`. Returns `null` for an invalid index, so the route
|
||||||
|
* answers 400 without mutating. Reused by the /nation-op/reject route.
|
||||||
|
*/
|
||||||
|
export function rejectNationOp(state: GameState, index: number): GameState | null {
|
||||||
|
if (!validPendingOpIndex(state, index)) return null;
|
||||||
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
|
next.version = (state.version ?? 0) + 1;
|
||||||
|
next.pendingNationOps.splice(index, 1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one turn end-to-end against the (injectable) LLM: build prompts, parse/
|
* Run one turn end-to-end against the (injectable) LLM: build prompts, parse/
|
||||||
* validate, retry once with a nudge if the result is empty/invalid, and — if
|
* validate, retry once with a nudge if the result is empty/invalid, and — if
|
||||||
@@ -674,6 +1017,7 @@ export async function runTurn(opts: {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-1
@@ -12,13 +12,15 @@ import { deepClone } from '../shared/protocol';
|
|||||||
import type {
|
import type {
|
||||||
AddActionRequest,
|
AddActionRequest,
|
||||||
AddDiplomacyRequest,
|
AddDiplomacyRequest,
|
||||||
|
ConfirmNationOpRequest,
|
||||||
CreateGameRequest,
|
CreateGameRequest,
|
||||||
|
RejectNationOpRequest,
|
||||||
RemoveQueueItemRequest,
|
RemoveQueueItemRequest,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
import { createLlmClient } from './llm';
|
import { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
@@ -128,6 +130,10 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
// starts with nothing released; this list grows as the LLM releases
|
// starts with nothing released; this list grows as the LLM releases
|
||||||
// regions and shrinks as they get re-claimed.
|
// regions and shrinks as they get re-claimed.
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
// (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the
|
||||||
|
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||||||
|
// shrinks as the player confirms or rejects them.
|
||||||
|
pendingNationOps: [],
|
||||||
};
|
};
|
||||||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
@@ -224,6 +230,38 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
|
|||||||
res.json({ game: next });
|
res.json({ game: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
|
||||||
|
// waiting in game.pendingNationOps: a rename takes effect (body.edits.name
|
||||||
|
// may override the proposed name), a split partitions the nation's territory
|
||||||
|
// into the proposed parts (body.edits.playerPart picks which part the player
|
||||||
|
// leads, default 0), and a merge folds its inputs into the survivor
|
||||||
|
// (body.edits.name may override the merged name). No LLM call, no turn
|
||||||
|
// advance — same fast save → broadcast → respond order as /queue/remove.
|
||||||
|
// The version bump lets "never go backwards" clients reconcile.
|
||||||
|
app.post('/api/games/:id/nation-op/confirm', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const game = db.load(id);
|
||||||
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
|
const body = (req.body ?? {}) as ConfirmNationOpRequest;
|
||||||
|
const next = confirmNationOp(game, body.index, body.edits);
|
||||||
|
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||||||
|
db.save(next);
|
||||||
|
broadcast(id, 'state', { state: next });
|
||||||
|
res.json({ game: next });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/games/:id/nation-op/reject', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const game = db.load(id);
|
||||||
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
|
const body = (req.body ?? {}) as RejectNationOpRequest;
|
||||||
|
const next = rejectNationOp(game, body.index);
|
||||||
|
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||||||
|
db.save(next);
|
||||||
|
broadcast(id, 'state', { state: next });
|
||||||
|
res.json({ game: next });
|
||||||
|
});
|
||||||
|
|
||||||
// Jump forward: commit the whole pending round (or an empty one) to the model
|
// Jump forward: commit the whole pending round (or an empty one) to the model
|
||||||
// in a single turn. applyTurnResult clears the pending queue on the returned
|
// in a single turn. applyTurnResult clears the pending queue on the returned
|
||||||
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const state: GameState = {
|
|||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
};
|
};
|
||||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ function truncate(s: string, n: number): string {
|
|||||||
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
||||||
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
||||||
* tests only (see server/engine.test.ts).
|
* tests only (see server/engine.test.ts).
|
||||||
|
*
|
||||||
|
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
||||||
|
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
||||||
|
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -121,6 +125,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||||
territoryChanges,
|
territoryChanges,
|
||||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||||
|
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,81 @@ export interface TerritoryChange {
|
|||||||
lose: string[];
|
lose: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||||
|
// proposes, player confirms" model) ------------------------------------------
|
||||||
|
//
|
||||||
|
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
|
||||||
|
// NOT applied by that turn. It is stored as a *pending proposal* in
|
||||||
|
// `GameState.pendingNationOps` and only takes effect when the player confirms
|
||||||
|
// it (optionally editing it) or is dropped when the player rejects it. All
|
||||||
|
// three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity reference a nation is addressed by inside a {@link NationOp}.
|
||||||
|
* The LLM may use either the nation's stable `id` (the `nations` record key,
|
||||||
|
* e.g. `f`) or its human-readable `name` (e.g. `France`); the engine resolves
|
||||||
|
* it to a concrete `nationId` (id first, then a unique name match — see the
|
||||||
|
* engine's `resolveNationRef`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||||
|
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||||
|
* by id), and the player pointer are all left untouched, so nothing downstream
|
||||||
|
* breaks. `to` is the new display name.
|
||||||
|
*/
|
||||||
|
export interface RenameOp {
|
||||||
|
kind: 'rename';
|
||||||
|
/** The target nation's id or current name (see the note above). */
|
||||||
|
nationRef: string;
|
||||||
|
/** The new display name to apply when the player confirms. */
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
|
||||||
|
* nation is divided into. On confirm the source is dissolved: each part gets a
|
||||||
|
* fresh id, the region keys it was given (restricted to keys the source
|
||||||
|
* actually owns — first claim wins), fresh baseline stats, and a copy of the
|
||||||
|
* source's relations; any of the source's unclaimed keys go to `parts[0]`.
|
||||||
|
* When the source is the player's own nation the player chooses which part
|
||||||
|
* they lead (the confirm request's `edits.playerPart`, default `parts[0]`).
|
||||||
|
*/
|
||||||
|
export interface SplitOp {
|
||||||
|
kind: 'split';
|
||||||
|
/** The id/name of the nation being split. */
|
||||||
|
nationRef: string;
|
||||||
|
/** The new nations created from the split. */
|
||||||
|
parts: Array<{ name: string; territory?: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
|
||||||
|
* surviving nation. On confirm the survivor keeps its id (renamed to `into`,
|
||||||
|
* or to the confirm request's `edits.name`), its territory becomes the union
|
||||||
|
* of all inputs' territories, it keeps its own stats, and it inherits the
|
||||||
|
* union of all inputs' relations (its own winning per third party, else the
|
||||||
|
* first input's); every non-survivor input is removed. If the player's nation
|
||||||
|
* was a non-survivor input the player pointer is re-pointed to the survivor.
|
||||||
|
*/
|
||||||
|
export interface MergeOp {
|
||||||
|
kind: 'merge';
|
||||||
|
/** The id/name of every nation folded into the result. */
|
||||||
|
nationRefs: string[];
|
||||||
|
/** The id/name of the nation that survives and keeps its identity. */
|
||||||
|
survivingRef: string;
|
||||||
|
/** The name to give the merged nation. */
|
||||||
|
into: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structural operation on the set of nations that the LLM may propose in a
|
||||||
|
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid
|
||||||
|
* propose→confirm semantics. All three kinds are implemented: rename (P4a),
|
||||||
|
* and split / merge (P4b, v0.24).
|
||||||
|
*/
|
||||||
|
export type NationOp = RenameOp | SplitOp | MergeOp;
|
||||||
|
|
||||||
/** One narrative event the LLM proposes for this turn. */
|
/** One narrative event the LLM proposes for this turn. */
|
||||||
export interface TurnEventSpec {
|
export interface TurnEventSpec {
|
||||||
nation: string;
|
nation: string;
|
||||||
@@ -56,6 +131,16 @@ export interface TurnResult {
|
|||||||
* empty on normal turns.
|
* empty on normal turns.
|
||||||
*/
|
*/
|
||||||
unowned: string[];
|
unowned: string[];
|
||||||
|
/**
|
||||||
|
* (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
|
||||||
|
* turn. These are NOT applied by the turn: each is appended to
|
||||||
|
* `GameState.pendingNationOps` as a proposal awaiting the player's
|
||||||
|
* confirm/reject. All three kinds (rename, split, merge) are implemented —
|
||||||
|
* a confirmed rename changes `Nation.name`; a confirmed split dissolves the
|
||||||
|
* source into its parts; a confirmed merge folds its inputs into the
|
||||||
|
* survivor. Empty on turns where the model proposes none.
|
||||||
|
*/
|
||||||
|
nationOps: NationOp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-region map payload: region key -> owning nation + its stable color. */
|
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||||
@@ -162,6 +247,39 @@ export interface RemoveQueueItemResponse {
|
|||||||
game: GameState;
|
game: GameState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
|
||||||
|
* index in `game.pendingNationOps`. A rename takes effect (the player may
|
||||||
|
* override the proposed name via `edits.name`); a split and a merge are fully
|
||||||
|
* applied (v0.24 P4b). No LLM call, no turn advance.
|
||||||
|
*/
|
||||||
|
export interface ConfirmNationOpRequest {
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* Optional player edits applied on confirm.
|
||||||
|
* - `name`: the final display name — overrides a rename's `to`, a merge's
|
||||||
|
* `into`, or (for a split) the name of the part the player leads.
|
||||||
|
* - `playerPart`: for a split of the player's own nation, which part (index
|
||||||
|
* into the op's `parts`) the player will lead (default `0` when missing or
|
||||||
|
* out of range, so the player is never orphaned).
|
||||||
|
*/
|
||||||
|
edits?: { name?: string; playerPart?: number };
|
||||||
|
}
|
||||||
|
export interface ConfirmNationOpResponse {
|
||||||
|
game: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index
|
||||||
|
* in `game.pendingNationOps` without applying it. No LLM call, no turn advance.
|
||||||
|
*/
|
||||||
|
export interface RejectNationOpRequest {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
export interface RejectNationOpResponse {
|
||||||
|
game: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdvisorRequest {
|
export interface AdvisorRequest {
|
||||||
/** Optional prompt to steer the brainstorm. */
|
/** Optional prompt to steer the brainstorm. */
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// Domain types — the single source of truth shared by the frontend (src/), the
|
// Domain types — the single source of truth shared by the frontend (src/), the
|
||||||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||||||
|
// (The single `import type` below erases at compile time, so no runtime
|
||||||
|
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
||||||
|
// doc comment.)
|
||||||
|
import type { NationOp } from './protocol';
|
||||||
|
|
||||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||||
export interface NationStats {
|
export interface NationStats {
|
||||||
@@ -133,6 +137,22 @@ export interface GameState {
|
|||||||
* this defaulted to `[]` (see server/db.ts).
|
* this defaulted to `[]` (see server/db.ts).
|
||||||
*/
|
*/
|
||||||
unowned: string[];
|
unowned: string[];
|
||||||
|
/**
|
||||||
|
* (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
|
||||||
|
* proposed but the player has not yet resolved: a *pending proposal* list,
|
||||||
|
* as opposed to `pending` (the current round's queued player actions). A
|
||||||
|
* turn NEVER applies one — `applyTurnResult` appends the turn's
|
||||||
|
* `nationOps` to this list, and each proposal survives across turns until
|
||||||
|
* the player resolves it: confirming applies it (a rename changes
|
||||||
|
* `Nation.name` only; a split partitions the nation's region-key territory
|
||||||
|
* into the new nations' territories; a merge folds the inputs' territory
|
||||||
|
* into the survivor) and removes it; rejecting removes it without
|
||||||
|
* applying. Always `[]` on a fresh game; rows saved before v0.23 load with
|
||||||
|
* this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
|
||||||
|
* protocol.ts; the import there is type-only, so this file still has no
|
||||||
|
* runtime dependencies.)
|
||||||
|
*/
|
||||||
|
pendingNationOps: NationOp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+55
@@ -177,6 +177,10 @@ export default function App() {
|
|||||||
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
||||||
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
||||||
const [advisorBusy, setAdvisorBusy] = useState(false);
|
const [advisorBusy, setAdvisorBusy] = useState(false);
|
||||||
|
// Separate again: a pending nation-op confirm/reject is its own (fast,
|
||||||
|
// non-LLM) request. It gates only the nation-op dialog controls, never the
|
||||||
|
// turn flow — "Jump forward" stays usable while it is in flight.
|
||||||
|
const [opBusy, setOpBusy] = useState(false);
|
||||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||||
// list of saved games (newest first) shown on the home screen.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -401,6 +405,44 @@ export default function App() {
|
|||||||
[game, applyState],
|
[game, applyState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Apply a pending nation op the model proposed (confirm; `edits.name`
|
||||||
|
// overrides a rename/merge name, `edits.playerPart` picks the player's
|
||||||
|
// successor part in a split). Fast, non-LLM — gated by `opBusy`, not `busy`.
|
||||||
|
// The version-bumped state comes back in the response and the SSE `state` event.
|
||||||
|
const runConfirmNationOp = useCallback(
|
||||||
|
async (index: number, edits?: { name?: string; playerPart?: number }) => {
|
||||||
|
if (!game) return;
|
||||||
|
setOpBusy(true);
|
||||||
|
try {
|
||||||
|
const { game: g } = await api.confirmNationOp(game.id, index, edits);
|
||||||
|
applyState(g);
|
||||||
|
toast.success('Nation change applied');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[game, applyState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop a pending nation op (reject / dismiss). Fast, non-LLM — `opBusy`.
|
||||||
|
const runRejectNationOp = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
if (!game) return;
|
||||||
|
setOpBusy(true);
|
||||||
|
try {
|
||||||
|
const { game: g } = await api.rejectNationOp(game.id, index);
|
||||||
|
applyState(g);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[game, applyState],
|
||||||
|
);
|
||||||
|
|
||||||
// Jump forward: the single LLM call that commits the pending round. Only this
|
// Jump forward: the single LLM call that commits the pending round. Only this
|
||||||
// sets `busy` (the adds above are fast and non-LLM).
|
// sets `busy` (the adds above are fast and non-LLM).
|
||||||
const runJump = useCallback(async () => {
|
const runJump = useCallback(async () => {
|
||||||
@@ -507,12 +549,15 @@ export default function App() {
|
|||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisorBusy={advisorBusy}
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={runAddAction}
|
onAddAction={runAddAction}
|
||||||
onAddDiplomacy={runAddDiplomacy}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
onRemoveQueueItem={runRemoveQueueItem}
|
onRemoveQueueItem={runRemoveQueueItem}
|
||||||
onAdvisor={askAdvisor}
|
onAdvisor={askAdvisor}
|
||||||
onJump={runJump}
|
onJump={runJump}
|
||||||
|
onConfirmNationOp={runConfirmNationOp}
|
||||||
|
onRejectNationOp={runRejectNationOp}
|
||||||
onHome={goHome}
|
onHome={goHome}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -551,12 +596,16 @@ interface GameScreenProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
||||||
advisorBusy: boolean;
|
advisorBusy: boolean;
|
||||||
|
/** A nation-op confirm/reject is in flight — drives the dialog controls only. */
|
||||||
|
opBusy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAddAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onAddDiplomacy: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
onRemoveQueueItem: (index: number) => void;
|
onRemoveQueueItem: (index: number) => void;
|
||||||
onAdvisor: (p?: string) => void;
|
onAdvisor: (p?: string) => void;
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,12 +628,15 @@ function GameScreen({
|
|||||||
regions,
|
regions,
|
||||||
busy,
|
busy,
|
||||||
advisorBusy,
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
onHome,
|
onHome,
|
||||||
}: GameScreenProps) {
|
}: GameScreenProps) {
|
||||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||||
@@ -835,12 +887,15 @@ function GameScreen({
|
|||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisorBusy={advisorBusy}
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={onAddAction}
|
onAddAction={onAddAction}
|
||||||
onAddDiplomacy={onAddDiplomacy}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
onRemoveQueueItem={onRemoveQueueItem}
|
onRemoveQueueItem={onRemoveQueueItem}
|
||||||
onAdvisor={onAdvisor}
|
onAdvisor={onAdvisor}
|
||||||
onJump={onJump}
|
onJump={onJump}
|
||||||
|
onConfirmNationOp={onConfirmNationOp}
|
||||||
|
onRejectNationOp={onRejectNationOp}
|
||||||
inWindow
|
inWindow
|
||||||
/>
|
/>
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
|
import { Loader2, MessageSquare, PenLine, Radar, Trash2, Zap } from 'lucide-react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
@@ -15,6 +15,7 @@ import { Input } from './ui/input';
|
|||||||
import { Textarea } from './ui/textarea';
|
import { Textarea } from './ui/textarea';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import NationOpDialog from './NationOpDialog';
|
||||||
import type { GameState, RoundItem } from '@shared/types';
|
import type { GameState, RoundItem } from '@shared/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +28,11 @@ interface Props {
|
|||||||
* never disable "Jump forward" or the rest of the turn flow.
|
* never disable "Jump forward" or the rest of the turn flow.
|
||||||
*/
|
*/
|
||||||
advisorBusy: boolean;
|
advisorBusy: boolean;
|
||||||
|
/**
|
||||||
|
* A pending nation-op confirm/reject request is in flight. Distinct from
|
||||||
|
* `busy`: it gates only the nation-op dialog controls, never the turn flow.
|
||||||
|
*/
|
||||||
|
opBusy: boolean;
|
||||||
/** Advisor brainstorm suggestions from the last request. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
@@ -38,6 +44,14 @@ interface Props {
|
|||||||
onAdvisor: (prompt?: string) => void;
|
onAdvisor: (prompt?: string) => void;
|
||||||
/** Commit the whole pending round in a single LLM turn. */
|
/** Commit the whole pending round in a single LLM turn. */
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
/**
|
||||||
|
* Apply a pending nation op: `edits.name` overrides a rename's target name
|
||||||
|
* (or a merge's name); `edits.playerPart` picks the player's successor part
|
||||||
|
* in a split (default 0).
|
||||||
|
*/
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
/** Drop (reject) a pending nation op by its index. */
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
/**
|
/**
|
||||||
* Render without the outer card chrome (the surrounding floating window
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
* provides the border and the "Your move" title). Same content, same testids.
|
* provides the border and the "Your move" title). Same content, same testids.
|
||||||
@@ -66,12 +80,15 @@ export default function ActionPanel({
|
|||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
advisorBusy,
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
inWindow = false,
|
inWindow = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [tab, setTab] = useState('action');
|
const [tab, setTab] = useState('action');
|
||||||
@@ -81,6 +98,8 @@ export default function ActionPanel({
|
|||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
// Index of the queued item the user is about to remove, or null (dialog closed).
|
// Index of the queued item the user is about to remove, or null (dialog closed).
|
||||||
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
||||||
|
// Whether the nation-op review dialog is open.
|
||||||
|
const [opDialogOpen, setOpDialogOpen] = useState(false);
|
||||||
|
|
||||||
const others = Object.entries(game.nations)
|
const others = Object.entries(game.nations)
|
||||||
.filter(([id]) => id !== game.playerNationId)
|
.filter(([id]) => id !== game.playerNationId)
|
||||||
@@ -108,6 +127,7 @@ export default function ActionPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pending = game.pending;
|
const pending = game.pending;
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
// The item the confirmation dialog is about (re-derived from the live list,
|
// The item the confirmation dialog is about (re-derived from the live list,
|
||||||
// so if the queue changes while the dialog is open it tracks the current
|
// so if the queue changes while the dialog is open it tracks the current
|
||||||
// item at that index — or the dialog closes if the index went out of range).
|
// item at that index — or the dialog closes if the index went out of range).
|
||||||
@@ -325,6 +345,31 @@ export default function ActionPanel({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Pending nation ops proposed by the model — open to confirm or reject. */}
|
||||||
|
{ops.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpDialogOpen(true)}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="nation-op-trigger"
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5 text-sm hover:bg-amber-500/15"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-amber-300">
|
||||||
|
<PenLine className="size-4" />
|
||||||
|
{ops.length} nation {ops.length === 1 ? 'proposal' : 'proposals'} — review
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-300/70">confirm or reject</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<NationOpDialog
|
||||||
|
game={game}
|
||||||
|
open={opDialogOpen}
|
||||||
|
onOpenChange={setOpDialogOpen}
|
||||||
|
opBusy={opBusy}
|
||||||
|
onConfirm={onConfirmNationOp}
|
||||||
|
onReject={onRejectNationOp}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||||
{queuedLabel}
|
{queuedLabel}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, Loader2, PenLine, X } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import type { GameState } from '@shared/types';
|
||||||
|
import type { NationOp, RenameOp } from '@shared/protocol';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameState;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** A confirm/reject request is in flight — disable the per-op controls. */
|
||||||
|
opBusy: boolean;
|
||||||
|
/**
|
||||||
|
* Apply a pending op: `edits.name` overrides a rename's target name (or the
|
||||||
|
* merged name for a merge); `edits.playerPart` picks which new part the
|
||||||
|
* player's nation becomes in a split (default 0).
|
||||||
|
*/
|
||||||
|
onConfirm: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
/** Drop a pending op (reject / dismiss). */
|
||||||
|
onReject: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, display-safe description of a pending nation op. */
|
||||||
|
function describeOp(op: NationOp, name: (ref: string) => string): string {
|
||||||
|
if (op.kind === 'rename') return `Rename ${name(op.nationRef)} to “${op.to}”`;
|
||||||
|
if (op.kind === 'split')
|
||||||
|
return `Split ${name(op.nationRef)} into ${op.parts.length} nations (${op.parts
|
||||||
|
.map((p) => p.name)
|
||||||
|
.join(', ')})`;
|
||||||
|
return `Merge ${op.nationRefs.map(name).join(' + ')} into “${op.into}”`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
|
||||||
|
* and lets the player apply ("LLM proposes, player confirms") or drop each one:
|
||||||
|
* a rename shows the proposed name in an editable field; a split lists the
|
||||||
|
* proposed parts (with the number of regions each is assigned) and, when the
|
||||||
|
* split parent is the player's own nation, radio buttons to pick which new
|
||||||
|
* part the player leads; a merge shows the input nations (the survivor
|
||||||
|
* highlighted) and an editable name for the merged state (default `into`).
|
||||||
|
*/
|
||||||
|
export default function NationOpDialog({
|
||||||
|
game,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
opBusy,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
}: Props) {
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
|
const name = (ref: string) => game.nations[ref]?.name ?? ref;
|
||||||
|
// Resolve a nation ref the way the server does (id first, then a unique
|
||||||
|
// case-insensitive name; null when unknown/ambiguous) — UI hints only, the
|
||||||
|
// server stays authoritative.
|
||||||
|
const resolveRef = (ref: string): string | null => {
|
||||||
|
const r = (ref ?? '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
if (game.nations[r]) return r;
|
||||||
|
const matches = Object.values(game.nations).filter(
|
||||||
|
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||||
|
);
|
||||||
|
return matches.length === 1 ? matches[0].id : null;
|
||||||
|
};
|
||||||
|
// The player's edits, keyed by pending-op index: the rename/merge target
|
||||||
|
// name (an unset entry means "use the model's proposed name"), and which
|
||||||
|
// split part the player succeeds to (preselected 0 — the radio is
|
||||||
|
// "required" and always has a valid answer).
|
||||||
|
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||||
|
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" data-testid="nation-op-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<PenLine className="size-4 text-primary" />
|
||||||
|
Nation proposals
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The model proposed changes to nations. Confirm to apply them (you can edit a
|
||||||
|
name and choose your successor state in a split) or reject to leave the world
|
||||||
|
as it is.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{ops.length === 0 ? (
|
||||||
|
<p className="py-2 text-sm text-muted-foreground" data-testid="nation-op-empty">
|
||||||
|
Nothing pending — the model hasn’t proposed any nation changes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
|
||||||
|
{ops.map((op, i) => {
|
||||||
|
const isRename = op.kind === 'rename';
|
||||||
|
const isSplit = op.kind === 'split';
|
||||||
|
const isMerge = op.kind === 'merge';
|
||||||
|
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
|
||||||
|
// Split: is the parent the player's own nation? (the successor radio
|
||||||
|
// is offered only in that case — the engine ignores playerPart otherwise)
|
||||||
|
const splitParentIsPlayer =
|
||||||
|
isSplit && resolveRef((op as { nationRef: string }).nationRef) === game.playerNationId;
|
||||||
|
const playerPart = playerParts[i] ?? 0;
|
||||||
|
// Merge: the survivor (highlighted) and whether the proposal is
|
||||||
|
// even valid (2+ distinct inputs; the survivor among them).
|
||||||
|
const survivorId = isMerge ? resolveRef((op as { survivingRef: string }).survivingRef) : null;
|
||||||
|
const inputIds = isMerge
|
||||||
|
? new Set(
|
||||||
|
(op as { nationRefs: string[] }).nationRefs
|
||||||
|
.map(resolveRef)
|
||||||
|
.filter((x): x is string => x !== null),
|
||||||
|
)
|
||||||
|
: new Set<string>();
|
||||||
|
const mergeValid =
|
||||||
|
!isMerge || (inputIds.size >= 2 && (survivorId !== null && inputIds.has(survivorId)));
|
||||||
|
const mergeName = isMerge ? (edits[i] ?? (op as { into: string }).into).trim() : '';
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (isRename) onConfirm(i, finalName ? { name: finalName } : undefined);
|
||||||
|
else if (isMerge) onConfirm(i, mergeName ? { name: mergeName } : undefined);
|
||||||
|
else onConfirm(i, splitParentIsPlayer ? { playerPart } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${i}-${op.kind}`}
|
||||||
|
className="rounded-lg border border-border/70 bg-card/50 p-3"
|
||||||
|
data-testid="nation-op-item"
|
||||||
|
data-kind={op.kind}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start gap-2">
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[11px] capitalize">
|
||||||
|
{op.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 text-sm leading-snug">
|
||||||
|
{describeOp(op, name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRename ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as RenameOp).to}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isSplit ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(op as { parts: { name: string; territory?: string[] }[] }).parts.map((p, pi) => (
|
||||||
|
<div
|
||||||
|
key={pi}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
data-testid="nation-op-part"
|
||||||
|
data-part={pi}
|
||||||
|
>
|
||||||
|
{splitParentIsPlayer ? (
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`player-part-${i}`}
|
||||||
|
checked={playerPart === pi}
|
||||||
|
onChange={() => setPlayerParts((prev) => ({ ...prev, [i]: pi }))}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="size-3.5 shrink-0 accent-amber-400"
|
||||||
|
aria-label={`Make ${p.name} the player's successor state`}
|
||||||
|
data-testid="nation-op-player-part"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{p.territory?.length ?? 0} region{(p.territory?.length ?? 0) === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{splitParentIsPlayer && (
|
||||||
|
<p className="text-[11px] text-muted-foreground" data-testid="nation-op-split-hint">
|
||||||
|
Choose which new state you will lead after the split.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(op as { parts: unknown[] }).parts.length < 2 && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
This split names fewer than two parts — confirming will dismiss it
|
||||||
|
without changing the world.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
|
||||||
|
const isSurvivor = resolveRef(ref) === survivorId;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={ri}
|
||||||
|
variant={isSurvivor ? 'default' : 'secondary'}
|
||||||
|
className="text-[11px]"
|
||||||
|
data-testid="nation-op-input"
|
||||||
|
>
|
||||||
|
{name(ref)}
|
||||||
|
{isSurvivor ? ' (survivor)' : ''}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as { into: string }).into}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
{!mergeValid && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
This merge is not valid as proposed — confirming will dismiss it
|
||||||
|
without changing the world.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
AddActionResponse,
|
AddActionResponse,
|
||||||
AddDiplomacyResponse,
|
AddDiplomacyResponse,
|
||||||
AdvisorResponse,
|
AdvisorResponse,
|
||||||
|
ConfirmNationOpResponse,
|
||||||
CreateGameResponse,
|
CreateGameResponse,
|
||||||
DeleteGameResponse,
|
DeleteGameResponse,
|
||||||
GameSummary,
|
GameSummary,
|
||||||
|
RejectNationOpResponse,
|
||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
@@ -60,6 +62,24 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ index }),
|
body: JSON.stringify({ index }),
|
||||||
}),
|
}),
|
||||||
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
|
||||||
|
// its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
|
||||||
|
// overrides the proposed name), a split partitions the nation's territory
|
||||||
|
// (`edits.playerPart` picks which part the player leads, default 0), and a
|
||||||
|
// merge folds its inputs into the survivor (`edits.name` overrides the
|
||||||
|
// merged name). Fast, non-LLM — no turn advance. Returns the updated game.
|
||||||
|
confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) =>
|
||||||
|
req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),
|
||||||
|
}),
|
||||||
|
// (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by index,
|
||||||
|
// without applying it. Fast, non-LLM — no turn advance. Returns the updated game.
|
||||||
|
rejectNationOp: (id: string, index: number) =>
|
||||||
|
req<RejectNationOpResponse>(`/api/games/${id}/nation-op/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index }),
|
||||||
|
}),
|
||||||
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
||||||
jump: (id: string) =>
|
jump: (id: string) =>
|
||||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user