Compare commits

..
5 Commits
12 changed files with 815 additions and 132 deletions
+44 -24
View File
@@ -68,13 +68,13 @@ history/
├── shared/ # NO runtime deps — the single source of truth ├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and │ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
│ # `nationOps`, v0.23 #16 P4a) + AddAction/AddDiplomacy/RemoveQueueItem/ │ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
│ # Jump + NationOp confirm/reject request types + pure helpers │ # Jump + NationOp confirm/reject request types + pure helpers
├── server/ ├── server/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor| │ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads │ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
│ │ # PORT/LLM_*/DATA_DIR │ │ # PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / applyNationOp / confirmNationOp / rejectNationOp, v0.23 #16 P4a) │ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config │ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency │ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER │ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
@@ -96,7 +96,7 @@ history/
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight ├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a) ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a)
├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename) or reject (v0.23 #16 P4a) ├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
├── EventFeed.tsx # scrolling event log (newest first) ├── EventFeed.tsx # scrolling event log (newest first)
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set ├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
├── StatBar.tsx ├── StatBar.tsx
@@ -189,24 +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).** `TurnResult.nationOps` (required - **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
in the schema) carries the LLM’s proposed **`NationOp`s** — a discriminated union of `rename` / `TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
`split` / `merge`. `rename` is fully applied; `split` and `merge` are **declared shapes only** discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
(accepted and stored as pending, but a no-op until P4b implements them). Each op names its target (P4b implemented split/merge). Each op names its target via `nationRef` — the LLM may use a
via `nationRef` — the LLM may use a nation’s **id or name**; `resolveNationRef` resolves id-first, nation’s **id or name**; `resolveNationRef` resolves id-first, then a case-insensitive
then a case-insensitive unique-name match, and returns `null` for an unknown or ambiguous ref (such unique-name match, and returns `null` for an unknown or ambiguous ref (such ops are dropped
ops are dropped defensively in `validateNationOps`, never thrown). A turn’s ops are appended to 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 `GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals
across turns until resolved (like the action queue). The player then decides per op via persist across turns until resolved (like the action queue). The player then decides per op via
`POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?}}`) or `.../nation-op/reject` `POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?, playerPart?}}`) or
(`{index}`): `confirmNationOp` applies the op to a clone (a rename sets `Nation.name` to the edited `.../nation-op/reject` (`{index}`): `confirmNationOp` applies the op to a clone — **rename** sets
value or, failing that, the proposed `to`; id / territory / relations / `playerNationId` stay valid, `Nation.name` to the edited value or, failing that, the proposed `to` (id / territory / relations /
so a renamed player is re-named in place) and `rejectNationOp` just drops it — both splice by index `playerNationId` stay valid, so a renamed player is re-named in place); **split** (the parent
and bump `version` once (an invalid index → `null` → HTTP 400). The response is `{ game }` and the must resolve and have ≥2 named parts, else a no-op) partitions the parent’s region-key territory
server broadcasts a version-bumped SSE `state`, so every tab’s version-guarded `applyState` stays in into new nations in parts order — each new nation gets a fresh id (`freshNationId`: first free of
sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel) with `n1`, `n2`, …) and the baseline `NEW_NATION_STATS` (not a copy of the parent’s), plus a copy of
an editable rename name plus Confirm / Reject. The stub never proposes a nation op (`nationOps: []`), the parent’s relations; a key named by several parts goes to the first, and the parent’s
so this path is covered by unit tests only (`server/engine.test.ts`). unclaimed keys go to part 0 (in territory order), so sole ownership holds and nothing is
orphaned; if the parent was the player’s nation, `playerNationId` moves to the part chosen by
`edits.playerPart` (clamped into range, default 0), which also receives `edits.name`; **merge**
(every ref must resolve, ≥2 DISTINCT inputs, and the survivor must be one of them, else a
no-op) folds the inputs into the survivor, which keeps its id and its own stats, gains the
union of the inputs’ territories (the survivor’s keys first) and a union of their relations
(the survivor’s own view of a third party wins, else the first input’s), is renamed
`edits.name` → `into` → its own name, while the other inputs are removed and their ids dropped
from every remaining nation’s relations maps; if the player led a removed input,
`playerNationId` moves to the survivor. An op that is invalid at confirm time (an ambiguous ref,
…) is a no-op on the world but is still removed — and `rejectNationOp` just drops it; both
splice by index and bump `version` once (an invalid index → `null` → HTTP 400). The response is
`{ game }` and the server broadcasts a version-bumped SSE `state`, so every tab’s
version-guarded `applyState` stays in sync. The client lists the pending ops in a “Nation
proposals” dialog (in the “Your move” panel): a rename and a merge show an editable name field
(the merge also highlights the survivor among the input chips); a split lists each part (name +
proposed region count) with a successor radio that appears only when the parent is the player’s
own nation (preselected part 0 — the choice is always valid). The system prompt documents all
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
(`server/engine.test.ts`).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).** - **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
@@ -233,9 +253,9 @@ history/
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a) covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a)
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge), so the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e still true since P4b implemented split/merge), so the confirm/reject path is covered by unit
assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally tests only, mirroring the P3 approach; all e2e assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally
bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so
keep the stub deterministic. keep the stub deterministic.
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The - **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
+13
View File
@@ -2,6 +2,19 @@
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 ## v0.23.0 — 2026-10-14 12:58
- **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b. - **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b.
- **New `NationOp` union (`shared/protocol.ts`).** `rename` / `split` / `merge`, discriminated by `kind`. Each names its target via a `nationRef` the LLM may give as either a nation **id** or **name**. `TurnResult.nationOps` is a **required** `NationOp[]` (the model's proposals this turn); the confirm/reject request types (`NationOpConfirmRequest` `{index, edits?{name?}}`, `NationOpRejectRequest` `{index}`, response `{game}`) live here too. - **New `NationOp` union (`shared/protocol.ts`).** `rename` / `split` / `merge`, discriminated by `kind`. Each names its target via a `nationRef` the LLM may give as either a nation **id** or **name**. `TurnResult.nationOps` is a **required** `NationOp[]` (the model's proposals this turn); the confirm/reject request types (`NationOpConfirmRequest` `{index, edits?{name?}}`, `NationOpRejectRequest` `{index}`, response `{game}`) live here too.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.23.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).",
+298 -7
View File
@@ -4,13 +4,16 @@ import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm'; import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub'; import { makeStubLlmClient } from './stub';
import { import {
applyNationOp,
applyTurnResult, applyTurnResult,
buildSystemPrompt, buildSystemPrompt,
buildUserPrompt, buildUserPrompt,
confirmNationOp, confirmNationOp,
freshNationId,
GET_NEIGHBOURS_TOOL, GET_NEIGHBOURS_TOOL,
isMeaningful, isMeaningful,
makeNeighboursExecutor, makeNeighboursExecutor,
NEW_NATION_STATS,
parseTurnResult, parseTurnResult,
playerFrontier, playerFrontier,
queueRoundItem, queueRoundItem,
@@ -634,7 +637,7 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
expect(next!.nations.us.name).toBe('United States'); expect(next!.nations.us.name).toBe('United States');
}); });
it('confirm is a no-op on the world for a P4b-kind op (split/merge) — the op is still removed', () => { it('confirm of a 1-part split is a no-op on the world (fewer than 2 parts) — the op is still removed', () => {
const state = baseState(); const state = baseState();
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }]; state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
const next = confirmNationOp(state, 0); const next = confirmNationOp(state, 0);
@@ -683,18 +686,306 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
}); });
}); });
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a)', () => { describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
it('both modes contract the nationOps field and the rename proposal rule', () => { it('both modes contract the nationOps field with all three op kinds and the proposal rule', () => {
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) { for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
// the JSON shape carries the field in both modes // the JSON shape carries all three kinds in both modes
expect(p).toMatch(/"nationOps": \[\{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"\}\]/); expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
// the rule explains that it is a proposal only the player can confirm expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
// the rule explains that ops are proposals only the player can confirm
expect(p).toMatch(/PROPOSAL/); expect(p).toMatch(/PROPOSAL/);
expect(p).toMatch(/only takes effect if the player confirms/); expect(p).toMatch(/only take effect if the player confirms/);
// and the split/merge shapes are spelt out in the hard constraints
expect(p).toMatch(/at least 2 parts/);
expect(p).toMatch(/survivingRef" must name one of them/);
} }
}); });
}); });
describe('freshNationId (v0.24 #16 P4b)', () => {
it('returns the first free of n1, n2, ... against the taken set', () => {
expect(freshNationId(new Set())).toBe('n1');
expect(freshNationId(new Set(['n1']))).toBe('n2');
expect(freshNationId(new Set(['n1', 'n2', 'n3']))).toBe('n4');
// non-n ids never collide: existing nation ids are irrelevant
expect(freshNationId(new Set(['uk', 'de', 'us']))).toBe('n1');
});
});
describe('applyNationOp (split, v0.24 #16 P4b)', () => {
/** de owns four regions; the player is uk (so a split of de does not move the pointer). */
function splitState(): GameState {
const s = baseState();
s.nations.de.territory = ['DEU-N', 'DEU-S', 'DEU-E', 'DEU-W'];
s.nations.de.relations = { uk: 'ally' };
s.nations.uk.relations.de = 'hostile'; // mirror entry, to check the cleanup
return s;
}
it('partitions the parent territory: first-wins per key, leftovers to part 0, fresh ids, baseline stats, copied relations, parent removed', () => {
const state = splitState();
applyNationOp(state, {
kind: 'split',
nationRef: 'de',
parts: [
{ name: 'East', territory: ['DEU-E', 'DEU-W', 'ZZZ-not-a-key'] },
{ name: 'West', territory: ['DEU-W', 'DEU-S'] },
],
});
// the parent is gone and two fresh nations exist (n1, n2 — uk/us/de were taken)
expect(state.nations.de).toBeUndefined();
expect(Object.keys(state.nations).sort()).toEqual(['n1', 'n2', 'uk', 'us']);
const east = state.nations.n1;
const west = state.nations.n2;
// East claimed DEU-E and DEU-W first (the invalid key is ignored); the
// only key no part claimed — DEU-N (DEU-S went to West) — goes to part 0
// in the parent's territory order.
expect(east.territory).toEqual(['DEU-E', 'DEU-W', 'DEU-N']);
// West's DEU-W was already claimed by East; it keeps only DEU-S.
expect(west.territory).toEqual(['DEU-S']);
// every parent key sits in exactly one part (sole ownership, nothing lost)
const all = [...east.territory, ...west.territory].sort();
expect(all).toEqual(['DEU-E', 'DEU-N', 'DEU-S', 'DEU-W']);
// fresh baseline stats (not the parent's) and inherited form of state
expect(east.stats).toEqual(NEW_NATION_STATS);
expect(west.stats).toEqual(NEW_NATION_STATS);
expect(east.type).toBe('major');
expect(west.type).toBe('major');
// both parts copied the parent's relations
expect(east.relations).toEqual({ uk: 'ally' });
expect(west.relations).toEqual({ uk: 'ally' });
// the mirror entry for the removed parent is cleaned up
expect(state.nations.uk.relations.de).toBeUndefined();
expect(state.nations.uk.relations.us).toBe('neutral'); // other entries untouched
// the player (uk) is not affected
expect(state.playerNationId).toBe('uk');
});
it('an unresolvable nationRef is a no-op (the world is untouched, never throws)', () => {
const state = splitState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'split',
nationRef: 'zzz-not-a-nation',
parts: [{ name: 'East' }, { name: 'West' }],
});
expect(state).toEqual(before);
});
it('a split named by the parent NAME works (ref resolution, not just id)', () => {
const state = splitState();
applyNationOp(state, {
kind: 'split',
nationRef: 'DE', // the name; the id is 'de'
parts: [{ name: 'East', territory: ['DEU-E'] }, { name: 'West', territory: ['DEU-W'] }],
});
expect(state.nations.de).toBeUndefined();
expect(state.nations.n1.name).toBe('East');
expect(state.nations.n2.name).toBe('West');
});
it('when the parent is the player nation, playerNationId moves to the chosen part (edits.playerPart: default 0, clamped in range)', () => {
const state = splitState();
state.playerNationId = 'de';
applyNationOp(state, {
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
});
expect(state.playerNationId).toBe('n1'); // default: the first part
const state2 = splitState();
state2.playerNationId = 'de';
applyNationOp(
state2,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
},
{ playerPart: 1 },
);
expect(state2.playerNationId).toBe('n2');
const state3 = splitState();
state3.playerNationId = 'de';
applyNationOp(
state3,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ playerPart: 99 }, // clamped down to the last part
);
expect(state3.playerNationId).toBe('n2');
const state4 = splitState();
state4.playerNationId = 'de';
applyNationOp(
state4,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ playerPart: -5 }, // clamped up to the first part
);
expect(state4.playerNationId).toBe('n1');
});
it('edits.name renames the chosen part (edits.playerPart picks which one)', () => {
const state = splitState();
applyNationOp(
state,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ playerPart: 1, name: 'New West' },
);
expect(state.nations.n1.name).toBe('East'); // untouched part keeps its name
expect(state.nations.n2.name).toBe('New West');
const state2 = splitState();
applyNationOp(
state2,
{
kind: 'split',
nationRef: 'de',
parts: [{ name: 'East' }, { name: 'West' }],
},
{ name: 'First of the Remnant' }, // no playerPart -> the first part
);
expect(state2.nations.n1.name).toBe('First of the Remnant');
expect(state2.nations.n2.name).toBe('West');
});
it('a 1-part split is a no-op on the world (fewer than 2 parts)', () => {
const state = splitState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, { kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] });
expect(state).toEqual(before);
});
});
describe('applyNationOp (merge, v0.24 #16 P4b)', () => {
/**
* de and us will be merged (the survivor is us — deliberately listed
* second in nationRefs, so the "survivor's view wins" rule can be observed
* overwriting the first input's views). uk, fr and ja survive as third
* parties; the survivor has no view on ja, so ja must come from the first
* input's map.
*/
function mergeState(): GameState {
const s = baseState();
s.nations.de.relations = { us: 'rival', uk: 'hostile', fr: 'rival', ja: 'rival' };
s.nations.us.relations = { de: 'ally', uk: 'ally', fr: 'neutral' }; // no view on ja
s.nations.uk.relations = { us: 'ally', de: 'hostile', fr: 'neutral', ja: 'neutral' };
s.nations.fr = {
id: 'fr',
type: 'major',
name: 'FR',
stats: { military: 40, economy: 60, stability: 70, population: 60 },
territory: ['FRA'],
relations: { us: 'neutral', de: 'rival', uk: 'neutral', ja: 'neutral' },
};
s.nations.ja = {
id: 'ja',
type: 'major',
name: 'JA',
stats: { military: 30, economy: 80, stability: 50, population: 90 },
territory: ['JPN'],
relations: { us: 'neutral', de: 'rival', uk: 'neutral', fr: 'neutral' },
};
return s;
}
it('folds the inputs into the survivor: union territory (survivor first), survivor stats, survivor view wins per third party (else first input), non-input third parties kept, removed ids dropped from everyone else, into-name', () => {
const state = mergeState();
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
// the removed input is gone; the survivor and all non-inputs survive
expect(state.nations.de).toBeUndefined();
expect(Object.keys(state.nations).sort()).toEqual(['fr', 'ja', 'uk', 'us']);
// the survivor keeps its id, its own stats, and the merged name
expect(state.nations.us.id).toBe('us');
expect(state.nations.us.stats).toEqual({ military: 60, economy: 90, stability: 80, population: 80 });
expect(state.nations.us.name).toBe('Union');
// territory: the union — the survivor's keys first, then the other input's
expect(state.nations.us.territory).toEqual(['USA', 'DEU']);
// relations: on uk and fr the survivor's own views (ally/neutral) beat the
// first input's (hostile/rival); ja has no survivor view, so the first
// input's (rival) stands; the removed input's id is gone from the map.
expect(state.nations.us.relations).toEqual({ uk: 'ally', fr: 'neutral', ja: 'rival' });
// the removed id is dropped from every remaining nation's relations map
expect(state.nations.uk.relations).toEqual({ us: 'ally', fr: 'neutral', ja: 'neutral' });
expect(state.nations.fr.relations).toEqual({ us: 'neutral', uk: 'neutral', ja: 'neutral' });
expect(state.nations.ja.relations).toEqual({ us: 'neutral', uk: 'neutral', fr: 'neutral' });
// the player (uk) is not part of the merge — the pointer stays valid
expect(state.playerNationId).toBe('uk');
});
it('the player pointer moves to the survivor when the player led a removed input', () => {
const state = mergeState();
state.playerNationId = 'de'; // the player's nation is absorbed
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
expect(state.playerNationId).toBe('us');
expect(state.nations[state.playerNationId].name).toBe('Union');
});
it('edits.name overrides the merged name; an empty edit falls back to "into", then to the survivor name', () => {
const state = mergeState();
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: 'United World' });
expect(state.nations.us.name).toBe('United World');
const state2 = mergeState();
applyNationOp(state2, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: ' ' });
expect(state2.nations.us.name).toBe('Union');
const state3 = mergeState();
applyNationOp(state3, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: '' }); // empty into: keep the survivor's name
expect(state3.nations.us.name).toBe('US');
});
it('an unresolvable input ref is a no-op (never throws, world untouched)', () => {
const state = mergeState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'merge',
nationRefs: ['us', 'zzz-not-a-nation'],
survivingRef: 'uk',
into: 'X',
});
expect(state).toEqual(before);
});
it('a survivor that is not one of the inputs is a no-op', () => {
const state = mergeState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'merge',
nationRefs: ['us', 'de'],
survivingRef: 'uk', // uk is not an input
into: 'X',
});
expect(state).toEqual(before);
});
it('fewer than two DISTINCT inputs is a no-op (the same nation named twice)', () => {
const state = mergeState();
const before = JSON.parse(JSON.stringify(state));
applyNationOp(state, {
kind: 'merge',
nationRefs: ['us', 'US'], // both resolve to 'us'
survivingRef: 'us',
into: 'X',
});
expect(state).toEqual(before);
});
});
describe('parseTurnResult', () => { describe('parseTurnResult', () => {
it('parses a fenced JSON block', () => { it('parses a fenced JSON block', () => {
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```'); const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
+204 -24
View File
@@ -7,6 +7,8 @@ import type {
EventOutcome, EventOutcome,
EventType, EventType,
GameState, GameState,
Nation,
NationStats,
Preset, Preset,
RelationStatus, RelationStatus,
RoundItem, RoundItem,
@@ -134,7 +136,9 @@ export function playerFrontier(
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool * When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
* available) the contract gains the contiguity rules for territory transfers * available) the contract gains the contiguity rules for territory transfers
* (v0.21, #16 P2): the model may only move regions across a shared border, and * (v0.21, #16 P2): the model may only move regions across a shared border, and
* may verify borders with the tool before answering. * may verify borders with the tool before answering. The contract also
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
*/ */
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string { export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
const rules = preset.simulationRules const rules = preset.simulationRules
@@ -178,14 +182,21 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],', ' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],', ' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
' "unowned": ["<region key>"],', ' "unowned": ["<region key>"],',
' "nationOps": [{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"}]', ' "nationOps": [',
' {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},',
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
' ],',
'}', '}',
'', '',
'HARD CONSTRAINTS', 'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.', '- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.', '- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".', '- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- To rename a nation, add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} to "nationOps". This is a PROPOSAL: it only takes effect if the player confirms it, so use it only when the narrative justifies it (a revolution, reunification, regime change) — at most 1-2 per turn — and narrate it in "events".', '- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.', '- Keep every stat value between 0 and 100.',
@@ -700,39 +711,208 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
return matches.length === 1 ? matches[0].id : null; return matches.length === 1 ? matches[0].id : null;
} }
/**
* Generate a fresh, unused nation id for a newly created nation (split
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
* each id it creates to `taken` as it goes, so ids stay unique within an
* op and are never reused from a removed nation.
*/
export function freshNationId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `n${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
* a copy of the parent's stats (a small rump state is not the parent's
* military power).
*/
export const NEW_NATION_STATS: NationStats = {
military: 50,
economy: 50,
stability: 50,
population: 50,
};
/**
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
* the removed nations' ids from every remaining nation's `relations` map so
* no key dangles. (The client already degrades gracefully on a missing
* target, but consistent data is cleaner.)
*/
function dropRelationsTo(state: GameState, removedIds: ReadonlySet<string>): void {
for (const n of Object.values(state.nations)) {
for (const id of removedIds) {
if (id in n.relations) delete n.relations[id];
}
}
}
/** /**
* Apply one NationOp to a state, MUTATING the passed object in place (the * Apply one NationOp to a state, MUTATING the passed object in place (the
* caller passes a clone — confirmNationOp does) and NOT bumping `version` * caller passes a clone — confirmNationOp does) and NOT bumping `version`
* (the route-level functions do that once per decision). Rename changes only * (the route-level functions do that once per decision). It never throws:
* `Nation.name` — the id, its territory, its relations (keyed by id), and the * any unresolvable reference or malformed op is a silent no-op (the op is
* player pointer all stay valid, so nothing downstream breaks; a * still removed by confirmNationOp — the world simply doesn't change).
* player-supplied `edits.name` overrides the proposed `to` (an empty edit *
* falls back to the proposal's own name). An unresolvable or ambiguous * - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
* `nationRef` is a silent no-op. Split and merge are declared for P4b and are * territory, its relations (keyed by id), and the player pointer all stay
* a no-op until then — the op stays pending-free once the player resolves it, * valid, so nothing downstream breaks; a player-supplied `edits.name`
* but the world does not change yet. * overrides the proposed `to` (an empty edit falls back to the proposal's
* own name).
*
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
* be at least two named parts, else no-op. Parts are processed in order;
* each receives the region keys of its `territory` that (a) the parent
* owns and (b) no earlier part claimed — the first part to name a key
* wins. Keys of the parent's territory that no part claimed go to part 0
* (in the parent's `territory` order), so the parent's territory is fully
* partitioned — no key is lost or ever held twice. Each part gets a
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
* copy of the parent's), plus a copy of the parent's relations; the
* parent is then removed. If the parent was the player's nation,
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
* into range, default 0); `edits.name` renames that same chosen part.
*
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
* resolve, at least two inputs must resolve to DISTINCT nations, and the
* survivor must be one of them — else no-op. The survivor keeps its id,
* keeps its own stats, gains the union of all inputs' territories
* (survivor's keys first, then each other input's unclaimed keys in its
* own order), and gains a union of the inputs' relations (on a third
* party the survivor's own view wins, else the first input's). The other
* inputs are removed (their ids dropped from everyone else's relations
* maps), and if the player led a removed input, `playerNationId` moves to
* the survivor. The merged name is `edits.name` (if non-empty), else
* `op.into`, else the survivor's current name.
*/ */
export function applyNationOp(state: GameState, op: NationOp, edits?: { name?: string }): void { export function applyNationOp(
if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet state: GameState,
const id = resolveNationRef(state, op.nationRef); op: NationOp,
if (id === null) return; // invalid/ambiguous ref: no-op edits?: { name?: string; playerPart?: number },
const n = state.nations[id]; ): void {
if (!n) return; if (op.kind === 'rename') {
const name = (edits?.name ?? '').trim() || op.to.trim(); const id = resolveNationRef(state, op.nationRef);
if (name) n.name = name; if (id === null) return; // invalid/ambiguous ref: no-op
const n = state.nations[id];
if (!n) return;
const name = (edits?.name ?? '').trim() || op.to.trim();
if (name) n.name = name;
return;
}
if (op.kind === 'split') {
const id = resolveNationRef(state, op.nationRef);
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
const parent = state.nations[id];
if (!parent) return;
const taken = new Set(Object.keys(state.nations));
const created: string[] = [];
const claimed = new Set<string>();
for (const part of op.parts) {
const newId = freshNationId(taken);
taken.add(newId);
const keys = (part.territory ?? [])
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
for (const k of keys) claimed.add(k);
const partNation: Nation = {
id: newId,
type: parent.type, // a part keeps the parent's form of state
name: part.name,
stats: { ...NEW_NATION_STATS },
territory: keys,
relations: { ...parent.relations },
};
state.nations[newId] = partNation;
created.push(newId);
}
// Keys the parent owned but no part claimed go to part 0 (in the
// parent's territory order) — nothing is lost or duplicated.
for (const k of parent.territory) {
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
}
// The player chooses which part is its successor (edits.playerPart,
// clamped into range; default 0) — only relevant when the parent was
// the player's nation; the same part also gets a player-edited name.
const partIdx =
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
if (state.playerNationId === id) state.playerNationId = created[partIdx];
const editedName = (edits?.name ?? '').trim();
if (editedName) state.nations[created[partIdx]].name = editedName;
delete state.nations[id];
dropRelationsTo(state, new Set([id]));
return;
}
// merge
const resolved: string[] = [];
for (const ref of op.nationRefs) {
const rid = resolveNationRef(state, ref);
if (rid === null) return; // any unresolvable input: no-op
resolved.push(rid);
}
const inputIds = new Set(resolved);
const survivorId = resolveNationRef(state, op.survivingRef);
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
const survivor = state.nations[survivorId];
if (!survivor) return;
// Territory: the union of all inputs — the survivor's keys first (stable
// order), then each other input's unclaimed keys in its own order.
const territory = [...survivor.territory];
const seen = new Set(territory);
for (const rid of resolved) {
for (const k of state.nations[rid].territory) {
if (!seen.has(k)) {
seen.add(k);
territory.push(k);
}
}
}
// Relations: the union of all inputs' relations; the first input to name a
// third party sets it, then the survivor's own view overwrites per key.
const relations: Record<string, RelationStatus> = {};
for (const rid of resolved) {
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
if (other === survivorId) continue; // never relate to oneself
if (!(other in relations)) relations[other] = status;
}
}
for (const [other, status] of Object.entries(survivor.relations)) {
if (other !== survivorId) relations[other] = status;
}
const removed = new Set<string>();
for (const rid of inputIds) {
if (rid === survivorId) continue;
delete state.nations[rid];
removed.add(rid);
}
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
state.playerNationId = survivorId;
}
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
if (name) survivor.name = name;
survivor.territory = territory;
survivor.relations = relations;
dropRelationsTo(state, removed);
} }
/** /**
* Confirm a pending nation-op proposal by index: apply it (a rename takes * Confirm a pending nation-op proposal by index: apply it (a rename takes
* effect, optionally with a player-edited name; split/merge are a no-op until * effect, optionally with a player-edited name; a split partitions the
* P4b) and remove it from `pendingNationOps`, bumping `version`. Returns * nation's territory into the new parts, the player picking which part is
* `null` for an invalid index, so the route answers 400 without mutating. * its successor via `edits.playerPart`; a merge folds the inputs into the
* Reused by the /nation-op/confirm route. * survivor, optionally renamed via `edits.name`) and remove it from
* `pendingNationOps`, bumping `version`. Returns `null` for an invalid
* index, so the route answers 400 without mutating. Reused by the
* /nation-op/confirm route.
*/ */
export function confirmNationOp( export function confirmNationOp(
state: GameState, state: GameState,
index: number, index: number,
edits?: { name?: string }, edits?: { name?: string; playerPart?: number },
): GameState | null { ): GameState | null {
if (!validPendingOpIndex(state, index)) return null; if (!validPendingOpIndex(state, index)) return null;
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
+8 -4
View File
@@ -230,10 +230,14 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
res.json({ game: next }); res.json({ game: next });
}); });
// (v0.23, #16 P4a) Resolve a pending nation-op proposal (a rename the LLM // (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
// proposed in an earlier turn, waiting in game.pendingNationOps). No LLM call, // waiting in game.pendingNationOps: a rename takes effect (body.edits.name
// no turn advance — same fast save → broadcast → respond order as // may override the proposed name), a split partitions the nation's territory
// /queue/remove. The version bump lets "never go backwards" clients reconcile. // into the proposed parts (body.edits.playerPart picks which part the player
// leads, default 0), and a merge folds its inputs into the survivor
// (body.edits.name may override the merged name). No LLM call, no turn
// advance — same fast save → broadcast → respond order as /queue/remove.
// The version bump lets "never go backwards" clients reconcile.
app.post('/api/games/:id/nation-op/confirm', (req, res) => { app.post('/api/games/:id/nation-op/confirm', (req, res) => {
const id = req.params.id; const id = req.params.id;
const game = db.load(id); const game = db.load(id);
+39 -24
View File
@@ -30,15 +30,14 @@ export interface TerritoryChange {
lose: string[]; lose: string[];
} }
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, player // --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
// confirms" model) ----------------------------------------------------------- // proposes, player confirms" model) ------------------------------------------
// //
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is // Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
// NOT applied by that turn. It is stored as a *pending proposal* in // NOT applied by that turn. It is stored as a *pending proposal* in
// `GameState.pendingNationOps` and only takes effect when the player confirms // `GameState.pendingNationOps` and only takes effect when the player confirms
// it (optionally editing it) or is dropped when the player rejects it. Rename // it (optionally editing it) or is dropped when the player rejects it. All
// is implemented in v0.23 (P4a); split and merge are declared here so the union // three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
// is stable for P4b, and are defensively accepted-but-not-applied until then.
/** /**
* The identity reference a nation is addressed by inside a {@link NationOp}. * The identity reference a nation is addressed by inside a {@link NationOp}.
@@ -63,9 +62,13 @@ export interface RenameOp {
} }
/** /**
* A **split** proposal (declared for #16 P4b; **not** applied by the engine yet * A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
* — it is accepted into the pending list, but confirming it is a no-op until * nation is divided into. On confirm the source is dissolved: each part gets a
* P4b lands). Describes the new nations a source nation is divided into. * fresh id, the region keys it was given (restricted to keys the source
* actually owns — first claim wins), fresh baseline stats, and a copy of the
* source's relations; any of the source's unclaimed keys go to `parts[0]`.
* When the source is the player's own nation the player chooses which part
* they lead (the confirm request's `edits.playerPart`, default `parts[0]`).
*/ */
export interface SplitOp { export interface SplitOp {
kind: 'split'; kind: 'split';
@@ -76,9 +79,13 @@ export interface SplitOp {
} }
/** /**
* A **merge** proposal (declared for #16 P4b; **not** applied by the engine yet * A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
* — accepted into the pending list, no-op on confirm until P4b). Folds several * surviving nation. On confirm the survivor keeps its id (renamed to `into`,
* nations into a single surviving nation. * or to the confirm request's `edits.name`), its territory becomes the union
* of all inputs' territories, it keeps its own stats, and it inherits the
* union of all inputs' relations (its own winning per third party, else the
* first input's); every non-survivor input is removed. If the player's nation
* was a non-survivor input the player pointer is re-pointed to the survivor.
*/ */
export interface MergeOp { export interface MergeOp {
kind: 'merge'; kind: 'merge';
@@ -93,8 +100,8 @@ export interface MergeOp {
/** /**
* A structural operation on the set of nations that the LLM may propose in a * A structural operation on the set of nations that the LLM may propose in a
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid * turn (`TurnResult.nationOps`). See the section comment above for the hybrid
* propose→confirm semantics. Rename is implemented (P4a); split/merge are * propose→confirm semantics. All three kinds are implemented: rename (P4a),
* declared for P4b and are a safe no-op until then. * and split / merge (P4b, v0.24).
*/ */
export type NationOp = RenameOp | SplitOp | MergeOp; export type NationOp = RenameOp | SplitOp | MergeOp;
@@ -125,12 +132,13 @@ export interface TurnResult {
*/ */
unowned: string[]; unowned: string[];
/** /**
* (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These * (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
* are NOT applied by the turn: each is appended to * turn. These are NOT applied by the turn: each is appended to
* `GameState.pendingNationOps` as a proposal awaiting the player's * `GameState.pendingNationOps` as a proposal awaiting the player's
* confirm/reject. Rename is the only kind the engine currently applies; * confirm/reject. All three kinds (rename, split, merge) are implemented —
* split/merge are declared for P4b and are accepted-but-dropped. Empty on * a confirmed rename changes `Nation.name`; a confirmed split dissolves the
* turns where the model proposes none. * source into its parts; a confirmed merge folds its inputs into the
* survivor. Empty on turns where the model proposes none.
*/ */
nationOps: NationOp[]; nationOps: NationOp[];
} }
@@ -240,15 +248,22 @@ export interface RemoveQueueItemResponse {
} }
/** /**
* (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in * (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
* `game.pendingNationOps`. A rename takes effect (the player may override the * index in `game.pendingNationOps`. A rename takes effect (the player may
* proposed name via `edits.name`); split/merge proposals are a no-op on * override the proposed name via `edits.name`); a split and a merge are fully
* confirm until P4b. No LLM call, no turn advance. * applied (v0.24 P4b). No LLM call, no turn advance.
*/ */
export interface ConfirmNationOpRequest { export interface ConfirmNationOpRequest {
index: number; index: number;
/** Optional player edits applied on confirm (rename: the final display name). */ /**
edits?: { name?: string }; * Optional player edits applied on confirm.
* - `name`: the final display name — overrides a rename's `to`, a merge's
* `into`, or (for a split) the name of the part the player leads.
* - `playerPart`: for a split of the player's own nation, which part (index
* into the op's `parts`) the player will lead (default `0` when missing or
* out of range, so the player is never orphaned).
*/
edits?: { name?: string; playerPart?: number };
} }
export interface ConfirmNationOpResponse { export interface ConfirmNationOpResponse {
game: GameState; game: GameState;
+13 -12
View File
@@ -138,18 +138,19 @@ export interface GameState {
*/ */
unowned: string[]; unowned: string[];
/** /**
* (v0.23, #16 P4a) Nation-management ops the LLM has proposed but the player * (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
* has not yet resolved: a *pending proposal* list, as opposed to `pending` * proposed but the player has not yet resolved: a *pending proposal* list,
* (the current round's queued player actions). A turn NEVER applies one — * as opposed to `pending` (the current round's queued player actions). A
* `applyTurnResult` appends the turn's `nationOps` to this list, and each * turn NEVER applies one — `applyTurnResult` appends the turn's
* proposal survives across turns until the player resolves it: confirming * `nationOps` to this list, and each proposal survives across turns until
* applies it (a rename changes `Nation.name` only — the id, its territory, * the player resolves it: confirming applies it (a rename changes
* its relations, and the player pointer all stay valid) and removes it; * `Nation.name` only; a split partitions the nation's region-key territory
* rejecting removes it without applying. Split/merge are declared for P4b * into the new nations' territories; a merge folds the inputs' territory
* and are a safe no-op on confirm until then. Always `[]` on a fresh game; * into the survivor) and removes it; rejecting removes it without
* rows saved before v0.23 load with this defaulted to `[]` (see server/db.ts). * applying. Always `[]` on a fresh game; rows saved before v0.23 load with
* (The `NationOp` type lives in protocol.ts; the import there is type-only, * this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
* so this file still has no runtime dependencies.) * protocol.ts; the import there is type-only, so this file still has no
* runtime dependencies.)
*/ */
pendingNationOps: NationOp[]; pendingNationOps: NationOp[];
} }
+6 -5
View File
@@ -405,11 +405,12 @@ export default function App() {
[game, applyState], [game, applyState],
); );
// Apply a pending nation op the model proposed (confirm, with an optional // Apply a pending nation op the model proposed (confirm; `edits.name`
// rename-name edit). Fast, non-LLM — gated by `opBusy`, not `busy`. The // overrides a rename/merge name, `edits.playerPart` picks the player's
// version-bumped state comes back in the response and the SSE `state` event. // successor part in a split). Fast, non-LLM — gated by `opBusy`, not `busy`.
// The version-bumped state comes back in the response and the SSE `state` event.
const runConfirmNationOp = useCallback( const runConfirmNationOp = useCallback(
async (index: number, edits?: { name?: string }) => { async (index: number, edits?: { name?: string; playerPart?: number }) => {
if (!game) return; if (!game) return;
setOpBusy(true); setOpBusy(true);
try { try {
@@ -603,7 +604,7 @@ interface GameScreenProps {
onRemoveQueueItem: (index: number) => void; onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void; onAdvisor: (p?: string) => void;
onJump: () => void; onJump: () => void;
onConfirmNationOp: (index: number, edits?: { name?: string }) => void; onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
onRejectNationOp: (index: number) => void; onRejectNationOp: (index: number) => void;
onHome: () => void; onHome: () => void;
} }
+6 -2
View File
@@ -44,8 +44,12 @@ interface Props {
onAdvisor: (prompt?: string) => void; onAdvisor: (prompt?: string) => void;
/** Commit the whole pending round in a single LLM turn. */ /** Commit the whole pending round in a single LLM turn. */
onJump: () => void; onJump: () => void;
/** Apply a pending nation op; `edits.name` optionally overrides a rename. */ /**
onConfirmNationOp: (index: number, edits?: { name?: string }) => void; * Apply a pending nation op: `edits.name` overrides a rename's target name
* (or a merge's name); `edits.playerPart` picks the player's successor part
* in a split (default 0).
*/
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
/** Drop (reject) a pending nation op by its index. */ /** Drop (reject) a pending nation op by its index. */
onRejectNationOp: (index: number) => void; onRejectNationOp: (index: number) => void;
/** /**
+176 -25
View File
@@ -19,8 +19,12 @@ interface Props {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** A confirm/reject request is in flight — disable the per-op controls. */ /** A confirm/reject request is in flight — disable the per-op controls. */
opBusy: boolean; opBusy: boolean;
/** Apply a pending op; `edits.name` optionally overrides a rename's target name. */ /**
onConfirm: (index: number, edits?: { name?: string }) => void; * Apply a pending op: `edits.name` overrides a rename's target name (or the
* merged name for a merge); `edits.playerPart` picks which new part the
* player's nation becomes in a split (default 0).
*/
onConfirm: (index: number, edits?: { name?: string; playerPart?: number }) => void;
/** Drop a pending op (reject / dismiss). */ /** Drop a pending op (reject / dismiss). */
onReject: (index: number) => void; onReject: (index: number) => void;
} }
@@ -37,10 +41,12 @@ function describeOp(op: NationOp, name: (ref: string) => string): string {
/** /**
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`) * Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
* and lets the player apply ("LLM proposes, player confirms") or drop each one. * and lets the player apply ("LLM proposes, player confirms") or drop each one:
* A rename shows the model's proposed name in an editable field (so the player * a rename shows the proposed name in an editable field; a split lists the
* can tweak it before confirming); split and merge are declared but not yet * proposed parts (with the number of regions each is assigned) and, when the
* applied (P4b) — they can be dismissed but not confirmed. * split parent is the player's own nation, radio buttons to pick which new
* part the player leads; a merge shows the input nations (the survivor
* highlighted) and an editable name for the merged state (default `into`).
*/ */
export default function NationOpDialog({ export default function NationOpDialog({
game, game,
@@ -52,9 +58,24 @@ export default function NationOpDialog({
}: Props) { }: Props) {
const ops = game.pendingNationOps; const ops = game.pendingNationOps;
const name = (ref: string) => game.nations[ref]?.name ?? ref; const name = (ref: string) => game.nations[ref]?.name ?? ref;
// The player's edit to a rename's target name, keyed by pending-op index. // Resolve a nation ref the way the server does (id first, then a unique
// An unset entry means "use the model's proposed name" (`op.to`). // case-insensitive name; null when unknown/ambiguous) — UI hints only, the
// server stays authoritative.
const resolveRef = (ref: string): string | null => {
const r = (ref ?? '').trim();
if (!r) return null;
if (game.nations[r]) return r;
const matches = Object.values(game.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
};
// The player's edits, keyed by pending-op index: the rename/merge target
// name (an unset entry means "use the model's proposed name"), and which
// split part the player succeeds to (preselected 0 — the radio is
// "required" and always has a valid answer).
const [edits, setEdits] = useState<Record<number, string>>({}); const [edits, setEdits] = useState<Record<number, string>>({});
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -68,7 +89,8 @@ export default function NationOpDialog({
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
The model proposed changes to nations. Confirm to apply them (you can edit a The model proposed changes to nations. Confirm to apply them (you can edit a
rename) or reject to leave the world as it is. name and choose your successor state in a split) or reject to leave the world
as it is.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -80,7 +102,34 @@ export default function NationOpDialog({
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list"> <ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
{ops.map((op, i) => { {ops.map((op, i) => {
const isRename = op.kind === 'rename'; const isRename = op.kind === 'rename';
const isSplit = op.kind === 'split';
const isMerge = op.kind === 'merge';
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : ''; const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
// Split: is the parent the player's own nation? (the successor radio
// is offered only in that case — the engine ignores playerPart otherwise)
const splitParentIsPlayer =
isSplit && resolveRef((op as { nationRef: string }).nationRef) === game.playerNationId;
const playerPart = playerParts[i] ?? 0;
// Merge: the survivor (highlighted) and whether the proposal is
// even valid (2+ distinct inputs; the survivor among them).
const survivorId = isMerge ? resolveRef((op as { survivingRef: string }).survivingRef) : null;
const inputIds = isMerge
? new Set(
(op as { nationRefs: string[] }).nationRefs
.map(resolveRef)
.filter((x): x is string => x !== null),
)
: new Set<string>();
const mergeValid =
!isMerge || (inputIds.size >= 2 && (survivorId !== null && inputIds.has(survivorId)));
const mergeName = isMerge ? (edits[i] ?? (op as { into: string }).into).trim() : '';
const confirm = () => {
if (isRename) onConfirm(i, finalName ? { name: finalName } : undefined);
else if (isMerge) onConfirm(i, mergeName ? { name: mergeName } : undefined);
else onConfirm(i, splitParentIsPlayer ? { playerPart } : undefined);
};
return ( return (
<li <li
key={`${i}-${op.kind}`} key={`${i}-${op.kind}`}
@@ -96,6 +145,7 @@ export default function NationOpDialog({
{describeOp(op, name)} {describeOp(op, name)}
</span> </span>
</div> </div>
{isRename ? ( {isRename ? (
<div className="space-y-2.5"> <div className="space-y-2.5">
<Input <Input
@@ -118,7 +168,72 @@ export default function NationOpDialog({
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={() => onConfirm(i, finalName ? { name: finalName } : undefined)} onClick={confirm}
disabled={opBusy}
data-testid="nation-op-confirm"
>
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Confirm
</Button>
</div>
</div>
) : isSplit ? (
<div className="space-y-2.5">
<div className="space-y-1">
{(op as { parts: { name: string; territory?: string[] }[] }).parts.map((p, pi) => (
<div
key={pi}
className="flex items-center gap-2 text-sm"
data-testid="nation-op-part"
data-part={pi}
>
{splitParentIsPlayer ? (
<input
type="radio"
name={`player-part-${i}`}
checked={playerPart === pi}
onChange={() => setPlayerParts((prev) => ({ ...prev, [i]: pi }))}
disabled={opBusy}
className="size-3.5 shrink-0 accent-amber-400"
aria-label={`Make ${p.name} the player's successor state`}
data-testid="nation-op-player-part"
/>
) : (
<span className="size-3.5 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{p.territory?.length ?? 0} region{(p.territory?.length ?? 0) === 1 ? '' : 's'}
</span>
</div>
))}
</div>
{splitParentIsPlayer && (
<p className="text-[11px] text-muted-foreground" data-testid="nation-op-split-hint">
Choose which new state you will lead after the split.
</p>
)}
{(op as { parts: unknown[] }).parts.length < 2 && (
<p className="text-[11px] text-muted-foreground">
This split names fewer than two parts — confirming will dismiss it
without changing the world.
</p>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onReject(i)}
disabled={opBusy}
className="text-muted-foreground hover:text-destructive"
data-testid="nation-op-reject"
>
<X className="size-3.5" />
Reject
</Button>
<Button
size="sm"
onClick={confirm}
disabled={opBusy} disabled={opBusy}
data-testid="nation-op-confirm" data-testid="nation-op-confirm"
> >
@@ -128,21 +243,57 @@ export default function NationOpDialog({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between gap-2"> <div className="space-y-2.5">
<span className="text-[11px] text-muted-foreground"> <div className="flex flex-wrap gap-1.5">
{op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it. {(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
</span> const isSurvivor = resolveRef(ref) === survivorId;
<Button return (
variant="ghost" <Badge
size="sm" key={ri}
onClick={() => onReject(i)} variant={isSurvivor ? 'default' : 'secondary'}
disabled={opBusy} className="text-[11px]"
className="text-muted-foreground hover:text-destructive" data-testid="nation-op-input"
data-testid="nation-op-reject" >
> {name(ref)}
<X className="size-3.5" /> {isSurvivor ? ' (survivor)' : ''}
Dismiss </Badge>
</Button> );
})}
</div>
<Input
value={edits[i] ?? (op as { into: string }).into}
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
placeholder="New name…"
data-testid="nation-op-name-input"
/>
{!mergeValid && (
<p className="text-[11px] text-muted-foreground">
This merge is not valid as proposed — confirming will dismiss it
without changing the world.
</p>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onReject(i)}
disabled={opBusy}
className="text-muted-foreground hover:text-destructive"
data-testid="nation-op-reject"
>
<X className="size-3.5" />
Reject
</Button>
<Button
size="sm"
onClick={confirm}
disabled={opBusy}
data-testid="nation-op-confirm"
>
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Confirm
</Button>
</div>
</div> </div>
)} )}
</li> </li>
+7 -4
View File
@@ -62,10 +62,13 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ index }), body: JSON.stringify({ index }),
}), }),
// (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in // (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
// `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the // its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
// proposed name). Fast, non-LLM — no turn advance. Returns the updated game. // overrides the proposed name), a split partitions the nation's territory
confirmNationOp: (id: string, index: number, edits?: { name?: string }) => // (`edits.playerPart` picks which part the player leads, default 0), and a
// merge folds its inputs into the survivor (`edits.name` overrides the
// merged name). Fast, non-LLM — no turn advance. Returns the updated game.
confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) =>
req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, { req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }), body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),