diff --git a/AGENTS.md b/AGENTS.md index d0dca8a..2e21123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ history/ ├── eslint.config.js # flat config (ESLint 9) ├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/ ├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases -├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 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) ├── index.html ├── presets/ # 5 scenario JSON files + index.json (manifest) @@ -67,10 +67,14 @@ history/ │ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests ├── shared/ # NO runtime deps — the single source of truth │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) -│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3) + 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) + AddAction/AddDiplomacy/RemoveQueueItem/ +│ # Jump + NationOp confirm/reject request types + pure helpers ├── server/ -│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR -│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor +│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor| +│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads +│ │ # PORT/LLM_*/DATA_DIR +│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / applyNationOp / confirmNationOp / rejectNationOp, v0.23 #16 P4a) │ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config │ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency │ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER @@ -91,7 +95,8 @@ history/ ├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens ├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier - ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a) + ├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename) or reject (v0.23 #16 P4a) ├── EventFeed.tsx # scrolling event log (newest first) ├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set ├── StatBar.tsx @@ -105,7 +110,7 @@ history/ | `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently | | `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` | | `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 | -| `npm test` | vitest unit tests (`server/` + `scripts/`; 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 lint` | ESLint flat config | | `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) | @@ -184,6 +189,24 @@ history/ — the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` / `unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). +- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a).** `TurnResult.nationOps` (required + in the schema) carries the LLM’s proposed **`NationOp`s** — a discriminated union of `rename` / + `split` / `merge`. `rename` is fully applied; `split` and `merge` are **declared shapes only** + (accepted and stored as pending, but a no-op until P4b implements them). Each op names its target + via `nationRef` — the LLM may use a nation’s **id or name**; `resolveNationRef` resolves id-first, + then a case-insensitive unique-name match, and returns `null` for an unknown or ambiguous ref (such + ops are dropped defensively in `validateNationOps`, never thrown). A turn’s ops are appended to + `GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals persist + across turns until resolved (like the action queue). The player then decides per op via + `POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?}}`) or `.../nation-op/reject` + (`{index}`): `confirmNationOp` applies the op to a clone (a rename sets `Nation.name` to the edited + value or, failing that, the proposed `to`; id / territory / relations / `playerNationId` stay valid, + so a renamed player is re-named in place) and `rejectNationOp` just drops it — both splice by index + and bump `version` once (an invalid index → `null` → HTTP 400). The response is `{ game }` and the + server broadcasts a version-bumped SSE `state`, so every tab’s version-guarded `applyState` stays in + sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel) with + an editable rename name plus Confirm / Reject. The stub never proposes a nation op (`nationOps: []`), + so this path is covered by unit tests only (`server/engine.test.ts`). - **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was @@ -209,9 +232,12 @@ history/ in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is - covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). 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. + covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a) + the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge), so + the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e + assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally + bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so + keep the stub deterministic. - **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers on resize and always floats above the other windows). The **dossier / event-feed / action** diff --git a/CHANGELOG.md b/CHANGELOG.md index 31714c8..f1a7656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to **History** are listed here, newest first. +## v0.23.0 — 2026-10-14 12:58 +- **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b. + - **New `NationOp` union (`shared/protocol.ts`).** `rename` / `split` / `merge`, discriminated by `kind`. Each names its target via a `nationRef` the LLM may give as either a nation **id** or **name**. `TurnResult.nationOps` is a **required** `NationOp[]` (the model's proposals this turn); the confirm/reject request types (`NationOpConfirmRequest` `{index, edits?{name?}}`, `NationOpRejectRequest` `{index}`, response `{game}`) live here too. + - **`GameState.pendingNationOps` (`shared/types.ts`, type-only import of `NationOp`)** — the proposals awaiting the player's decision. A turn **appends** to it (never clears it on commit), so a proposal persists across turns until confirmed or rejected. Legacy SQLite rows and new games default it to `[]` (`server/db.ts`). + - **Engine (`server/engine.ts`), all pure.** `resolveNationRef` resolves a `nationRef` id-first, then a case-insensitive unique-name match (returns `null` for unknown/ambiguous — never throws). `validateNationOps` drops invalid ops defensively. `applyTurnResult` keeps existing pending ops and appends the turn's valid ones (a rename-only turn is `isMeaningful`, so it survives the retry/nudge path). `applyNationOp` mutates a clone (rename → `Nation.name` = edited value or the proposed `to`; split/merge are no-ops for now). `confirmNationOp`/`rejectNationOp` splice by index and bump `version` once (invalid index → `null`). The system prompt (both tool-mode and plain-mode) gains the `nationOps` JSON field and the rename-proposal rule. + - **Routes + client.** `POST /api/games/:id/nation-op/confirm` and `.../reject` (404 unknown game, 400 bad index), each returning `{ game }` and broadcasting a version-bumped SSE `state` — mirroring the existing queue add/remove routes. The "Your move" panel shows a "Nation proposals (N)" trigger that opens a dialog listing each pending op; a rename has an editable name field plus **Confirm**/**Reject**, and split/merge offer **Dismiss** (they aren't applied yet). + - **Stub + tests.** The deterministic stub **never** proposes a nation op (`nationOps: []`), so this path is covered by **unit tests only** (mirroring the P3 no-man's-land approach) — `server/engine.test.ts` gains suites for `resolveNationRef`, `validateNationOps`, `applyNationOp`/confirm/reject (incl. an invalid-index 400 and the version-bump), and the prompt contract. The unit suite grows **127 → 151**; the e2e suite is unaffected. +- **Verification.** Typecheck, lint, **151 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) has no nation-op assertions, so it stays green with the unchanged stub. +- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`). +- Part of #16 — major nation changes (P4a); #16 stays open (P4b: split/merge). + ## v0.22.0 — 2026-10-14 12:02 - **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game). - **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming. diff --git a/package.json b/package.json index 7fa76c7..742f17c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.22.0", + "version": "0.23.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/db.test.ts b/server/db.test.ts index d0e9f98..f1176dd 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -24,6 +24,7 @@ function makeState(id: string): GameState { pending: [], version: 1, unowned: [], + pendingNationOps: [], }; } @@ -85,6 +86,17 @@ describe('openDb', () => { db.close(); }); + it('(v0.23 #16 P4a) a legacy row saved before the pendingNationOps field existed loads with it defaulted to []', () => { + const db = openDb(':memory:'); + const legacy = makeState('legacy'); + delete (legacy as { pendingNationOps?: unknown[] }).pendingNationOps; // a pre-v0.23 save (field absent) + db.save(legacy); + const loaded = db.load('legacy'); + expect(loaded).not.toBeNull(); + expect(loaded!.pendingNationOps).toEqual([]); + db.close(); + }); + it('delete removes a game by id and reports absence otherwise', () => { const db = openDb(':memory:'); db.save(makeState('a')); diff --git a/server/db.ts b/server/db.ts index 89706c8..eb54a4a 100644 --- a/server/db.ts +++ b/server/db.ts @@ -43,6 +43,9 @@ export function openDb(filePath: string): Db { // (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load // with it defaulted: a legacy game has no no-man's-land regions. if (state.unowned === undefined) state.unowned = []; + // (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game + // has none awaiting confirm/reject. + if (state.pendingNationOps === undefined) state.pendingNationOps = []; return state; } catch { return null; diff --git a/server/engine.test.ts b/server/engine.test.ts index 8393ef2..870a090 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -1,20 +1,24 @@ import { describe, expect, it } from 'vitest'; import type { GameState, Preset, RoundItem } from '../shared/types'; -import { makeEvent, type TurnResult } from '../shared/protocol'; +import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol'; import type { LlmClient, ToolDefinition, WireMessage } from './llm'; import { makeStubLlmClient } from './stub'; import { applyTurnResult, buildSystemPrompt, buildUserPrompt, + confirmNationOp, GET_NEIGHBOURS_TOOL, isMeaningful, makeNeighboursExecutor, parseTurnResult, playerFrontier, queueRoundItem, + rejectNationOp, removeRoundItem, + resolveNationRef, runTurn, + validateNationOps, validateTurnResult, } from './engine'; @@ -79,6 +83,7 @@ function baseState(): GameState { pending: [], version: 1, unowned: [], + pendingNationOps: [], }; } @@ -425,6 +430,271 @@ 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 is a no-op on the world for a P4b-kind op (split/merge) — 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)', () => { + it('both modes contract the nationOps field and the rename proposal rule', () => { + for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) { + // the JSON shape carries the field in both modes + expect(p).toMatch(/"nationOps": \[\{"kind":"rename","nationRef":"","to":""\}\]/); + // the rule explains that it is a proposal only the player can confirm + expect(p).toMatch(/PROPOSAL/); + expect(p).toMatch(/only takes effect if the player confirms/); + } + }); +}); + describe('parseTurnResult', () => { it('parses a fenced JSON block', () => { const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```'); @@ -478,6 +748,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], + nationOps: [], }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -493,6 +764,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], + nationOps: [], }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -514,6 +786,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], + nationOps: [], }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -538,6 +811,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], + nationOps: [], }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -554,6 +828,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { stateChanges: [], relations: [], unowned: [], + nationOps: [], }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -642,6 +917,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], + nationOps: [], }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -756,7 +1032,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'] }), + isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }), ).toBe(true); }); @@ -1063,8 +1339,22 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).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: [], relations: [], territoryChanges: [], unowned: [] })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false); + }); + + it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { + expect( + isMeaningful({ + date: 'x', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], + }), + ).toBe(true); }); }); diff --git a/server/engine.ts b/server/engine.ts index d19a38b..4903f32 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -19,6 +19,7 @@ import { isDateLike, makeEvent, nationForPrompt, + type NationOp, type TurnEventSpec, type TurnResult, } from '../shared/protocol'; @@ -176,13 +177,15 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' "stateChanges": [{"nation":"","stat":"","op":"","value":<0-100>}],', ' "relations": [{"a":"","b":"","set":""}],', ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}],', - ' "unowned": [""]', + ' "unowned": [""],', + ' "nationOps": [{"kind":"rename","nationRef":"","to":""}]', '}', '', 'HARD CONSTRAINTS', '- Use only nation ids and region keys that exist in the provided state.', '- A region key identifies one sub-region; the "regions" table in the user message maps each key to " ()". Use those keys in territoryChanges and unowned.', '- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".', + '- To rename a nation, add {"kind":"rename","nationRef":"","to":""} 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".', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep every stat value between 0 and 100.', @@ -316,6 +319,53 @@ function str(s: unknown, max = 0): string | null { return max ? t.slice(0, max) : t; } +/** + * Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values + * (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an + * unknown `kind` or missing/empty required fields are dropped, never throw. + * Rename: non-empty `nationRef` + `to` (each capped at 200 chars — resolution + * of `nationRef` to a concrete nation happens later, at confirm time). Split: + * non-empty `nationRef` + at least one part with a name. Merge: non-empty + * `nationRefs` (capped at 20) + `survivingRef` + `into`. + */ +export function validateNationOps(raw: unknown): NationOp[] { + if (!Array.isArray(raw)) return []; + const out: NationOp[] = []; + for (const e of raw) { + if (!e || typeof e !== 'object') continue; + const o = e as Record; + 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 => !!p && typeof p === 'object') + .map((p) => { + const name = str(p.name, 200); + const territory = Array.isArray(p.territory) + ? p.territory.filter((k): k is string => typeof k === 'string' && k.trim() !== '') + : []; + return { name, territory }; + }) + .filter((p): p is { name: string; territory: string[] } => p.name !== null); + if (nationRef && parts.length > 0) out.push({ kind: 'split', nationRef, parts }); + } else if (o.kind === 'merge') { + const nationRefs = (Array.isArray(o.nationRefs) ? o.nationRefs : []) + .map((x) => (typeof x === 'string' ? x.trim().slice(0, 200) : '')) + .filter((x) => x !== '') + .slice(0, 20); + const survivingRef = str(o.survivingRef, 200); + const into = str(o.into, 200); + if (nationRefs.length > 0 && survivingRef && into) { + out.push({ kind: 'merge', nationRefs, survivingRef, into }); + } + } + } + return out; +} + /** * Validate and normalize an arbitrary parsed object into a safe TurnResult. * Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null @@ -331,6 +381,7 @@ export function validateTurnResult(input: unknown): TurnResult | null { relations: [], territoryChanges: [], unowned: [], + nationOps: [], }; if (Array.isArray(o.events)) { @@ -415,17 +466,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; } /** - * A turn is "meaningful" if it produced at least an event, a stat change, or - * a 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 - * instead of being replaced by the generic "unclear" result). + * A turn is "meaningful" if it produced at least an event, a stat change, a + * no-man's-land release (v0.22 #16 P3: a release is a real world change even + * when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a: + * a proposal is a real pending-world change the player will resolve, so it + * must survive the retry/nudge path instead of being replaced by the generic + * "unclear" result). */ export function isMeaningful(result: TurnResult): boolean { - return result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0; + return ( + result.events.length > 0 || + result.stateChanges.length > 0 || + result.unowned.length > 0 || + result.nationOps.length > 0 + ); } // --- Applying the delta ------------------------------------------------------- @@ -443,7 +506,10 @@ function genericUnclearEvent(state: GameState): TurnEventSpec { /** * Apply a TurnResult to a GameState immutably: advance the date/turn, append * events, apply stat changes (clamped), symmetric relations, and territory - * moves (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): GameState { const next: GameState = JSON.parse(JSON.stringify(state)); @@ -559,6 +625,13 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys? } 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; } @@ -593,6 +666,96 @@ export function removeRoundItem(state: GameState, index: number | undefined): Ga return next; } +// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, +// player confirms" model) ---------------------------------------------------- +// +// A NationOp proposed by a turn lands in `GameState.pendingNationOps` (see +// applyTurnResult) and waits there across turns. These functions resolve the +// player's decision on one pending proposal, by index. Like removeRoundItem +// they are pure: clone the state, mutate the clone, bump `version` once, +// return the new state — and return `null` for a bad index so the route can +// answer 400 without mutating anything. + +/** True when `index` is a valid, in-bounds integer position in the pending list. */ +function validPendingOpIndex(state: GameState, index: number): boolean { + const len = state.pendingNationOps?.length ?? 0; + return Number.isInteger(index) && index >= 0 && index < len; +} + +/** + * Resolve a nation reference (the `nationRef` of a NationOp — the LLM may + * write either the nation's stable `id` or its human-readable `name`) to a + * concrete nation id: an exact id match wins, then a unique case-insensitive + * name match. Returns `null` when the reference is empty, unknown, or + * ambiguous (two or more nations share that name) — every caller treats + * `null` as "no-op" and nothing here ever throws. + */ +export function resolveNationRef(state: GameState, ref: string): string | null { + const r = (ref ?? '').trim(); + if (!r) return null; + if (state.nations[r]) return r; // exact id first + const matches = Object.values(state.nations).filter( + (n) => n.name.trim().toLowerCase() === r.toLowerCase(), + ); + return matches.length === 1 ? matches[0].id : null; +} + +/** + * Apply one NationOp to a state, MUTATING the passed object in place (the + * caller passes a clone — confirmNationOp does) and NOT bumping `version` + * (the route-level functions do that once per decision). Rename changes only + * `Nation.name` — the id, its territory, its relations (keyed by id), and the + * player pointer all stay valid, so nothing downstream breaks; a + * player-supplied `edits.name` overrides the proposed `to` (an empty edit + * falls back to the proposal's own name). An unresolvable or ambiguous + * `nationRef` is a silent no-op. Split and merge are declared for P4b and are + * a no-op until then — the op stays pending-free once the player resolves it, + * but the world does not change yet. + */ +export function applyNationOp(state: GameState, op: NationOp, edits?: { name?: string }): void { + if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet + const id = resolveNationRef(state, op.nationRef); + if (id === null) return; // invalid/ambiguous ref: no-op + const n = state.nations[id]; + if (!n) return; + const name = (edits?.name ?? '').trim() || op.to.trim(); + if (name) n.name = name; +} + +/** + * Confirm a pending nation-op proposal by index: apply it (a rename takes + * effect, optionally with a player-edited name; split/merge are a no-op until + * P4b) and remove it from `pendingNationOps`, bumping `version`. Returns + * `null` for an invalid index, so the route answers 400 without mutating. + * Reused by the /nation-op/confirm route. + */ +export function confirmNationOp( + state: GameState, + index: number, + edits?: { name?: string }, +): GameState | null { + if (!validPendingOpIndex(state, index)) return null; + const next: GameState = JSON.parse(JSON.stringify(state)); + next.version = (state.version ?? 0) + 1; + const op = next.pendingNationOps[index]; + applyNationOp(next, op, edits); + next.pendingNationOps.splice(index, 1); + return next; +} + +/** + * Reject (drop) a pending nation-op proposal by index without applying it, + * bumping `version`. Returns `null` for an invalid index, so the route + * answers 400 without mutating. Reused by the /nation-op/reject route. + */ +export function rejectNationOp(state: GameState, index: number): GameState | null { + if (!validPendingOpIndex(state, index)) return null; + const next: GameState = JSON.parse(JSON.stringify(state)); + next.version = (state.version ?? 0) + 1; + next.pendingNationOps.splice(index, 1); + return next; +} + /** * Run one turn end-to-end against the (injectable) LLM: build prompts, parse/ * validate, retry once with a nudge if the result is empty/invalid, and — if @@ -674,6 +837,7 @@ export async function runTurn(opts: { relations: [], territoryChanges: [], unowned: [], + nationOps: [], }; } diff --git a/server/index.ts b/server/index.ts index ffee203..12de785 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,13 +12,15 @@ import { deepClone } from '../shared/protocol'; import type { AddActionRequest, AddDiplomacyRequest, + ConfirmNationOpRequest, CreateGameRequest, + RejectNationOpRequest, RemoveQueueItemRequest, } from '../shared/protocol'; import type { GameState } from '../shared/types'; import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; -import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine'; +import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine'; import { buildRegionMap } from './map'; import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions'; import { createPresetStore } from './presets'; @@ -128,6 +130,10 @@ app.post('/api/games', (req: Request, res) => { // starts with nothing released; this list grows as the LLM releases // regions and shrinks as they get re-claimed. unowned: [], + // (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the + // list grows as the LLM proposes renames (and, later, splits/merges) and + // shrinks as the player confirms or rejects them. + pendingNationOps: [], }; // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // of all its sub-regions, so ownership (and the choropleth) is at region @@ -224,6 +230,34 @@ app.post('/api/games/:id/queue/remove', (req, res) => { res.json({ game: next }); }); +// (v0.23, #16 P4a) Resolve a pending nation-op proposal (a rename the LLM +// proposed in an earlier turn, waiting in game.pendingNationOps). No LLM call, +// no turn advance — same fast save → broadcast → respond order as +// /queue/remove. The version bump lets "never go backwards" clients reconcile. +app.post('/api/games/:id/nation-op/confirm', (req, res) => { + const id = req.params.id; + const game = db.load(id); + if (!game) return res.status(404).json({ error: 'game not found' }); + const body = (req.body ?? {}) as ConfirmNationOpRequest; + const next = confirmNationOp(game, body.index, body.edits); + if (!next) return res.status(400).json({ error: 'invalid proposal index' }); + db.save(next); + broadcast(id, 'state', { state: next }); + res.json({ game: next }); +}); + +app.post('/api/games/:id/nation-op/reject', (req, res) => { + const id = req.params.id; + const game = db.load(id); + if (!game) return res.status(404).json({ error: 'game not found' }); + const body = (req.body ?? {}) as RejectNationOpRequest; + const next = rejectNationOp(game, body.index); + if (!next) return res.status(400).json({ error: 'invalid proposal index' }); + db.save(next); + broadcast(id, 'state', { state: next }); + res.json({ game: next }); +}); + // Jump forward: commit the whole pending round (or an empty one) to the model // in a single turn. applyTurnResult clears the pending queue on the returned // state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors. diff --git a/server/map.test.ts b/server/map.test.ts index 7f34ad1..f469f4e 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -48,6 +48,7 @@ const state: GameState = { pending: [], version: 1, unowned: [], + pendingNationOps: [], }; // The RegionMap built from the fixture: the authoritative region key → owner source. diff --git a/server/regions.test.ts b/server/regions.test.ts index d6a3dd9..567c3a4 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -87,6 +87,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { pending: [], version: 1, unowned: [], + pendingNationOps: [], }; // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. expect(allTerritoryKeys(state).size).toBe(58); diff --git a/server/stub.ts b/server/stub.ts index 85e1763..3e09a95 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -57,6 +57,10 @@ function truncate(s: string, n: number): string { * always returns `unowned: []`) — e2e assertions rely on its fully-deterministic * one-conquest behavior, so the release/re-claim path is exercised by unit * tests only (see server/engine.test.ts). + * + * (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it + * always returns `nationOps: []`) — the propose→confirm/reject flow is + * covered by unit tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -121,6 +125,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [], territoryChanges, unowned: [], // the stub never creates no man's land (see doc comment) + nationOps: [], // the stub never proposes nation ops (see doc comment) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index eedb80f..a8b73d0 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -30,6 +30,74 @@ export interface TerritoryChange { lose: string[]; } +// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, player +// confirms" model) ----------------------------------------------------------- +// +// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is +// NOT applied by that turn. It is stored as a *pending proposal* in +// `GameState.pendingNationOps` and only takes effect when the player confirms +// it (optionally editing it) or is dropped when the player rejects it. Rename +// is implemented in v0.23 (P4a); split and merge are declared here so the union +// is stable for P4b, and are defensively accepted-but-not-applied until then. + +/** + * 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 (declared for #16 P4b; **not** applied by the engine yet + * — it is accepted into the pending list, but confirming it is a no-op until + * P4b lands). Describes the new nations a source nation is divided into. + */ +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 (declared for #16 P4b; **not** applied by the engine yet + * — accepted into the pending list, no-op on confirm until P4b). Folds several + * nations into a single surviving nation. + */ +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. Rename is implemented (P4a); split/merge are + * declared for P4b and are a safe no-op until then. + */ +export type NationOp = RenameOp | SplitOp | MergeOp; + /** One narrative event the LLM proposes for this turn. */ export interface TurnEventSpec { nation: string; @@ -56,6 +124,15 @@ export interface TurnResult { * empty on normal turns. */ unowned: string[]; + /** + * (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These + * are NOT applied by the turn: each is appended to + * `GameState.pendingNationOps` as a proposal awaiting the player's + * confirm/reject. Rename is the only kind the engine currently applies; + * split/merge are declared for P4b and are accepted-but-dropped. Empty on + * turns where the model proposes none. + */ + nationOps: NationOp[]; } /** Per-region map payload: region key -> owning nation + its stable color. */ @@ -162,6 +239,32 @@ export interface RemoveQueueItemResponse { game: GameState; } +/** + * (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in + * `game.pendingNationOps`. A rename takes effect (the player may override the + * proposed name via `edits.name`); split/merge proposals are a no-op on + * confirm until P4b. No LLM call, no turn advance. + */ +export interface ConfirmNationOpRequest { + index: number; + /** Optional player edits applied on confirm (rename: the final display name). */ + edits?: { name?: string }; +} +export interface ConfirmNationOpResponse { + game: GameState; +} + +/** + * (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index + * in `game.pendingNationOps` without applying it. No LLM call, no turn advance. + */ +export interface RejectNationOpRequest { + index: number; +} +export interface RejectNationOpResponse { + game: GameState; +} + export interface AdvisorRequest { /** Optional prompt to steer the brainstorm. */ prompt?: string; diff --git a/shared/types.ts b/shared/types.ts index 297290b..0d369ad 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,5 +1,9 @@ // Domain types — the single source of truth shared by the frontend (src/), the // backend (server/), and the LLM prompt contract. No runtime dependencies here. +// (The single `import type` below erases at compile time, so no runtime +// dependency on protocol.ts is introduced — see the GameState.pendingNationOps +// doc comment.) +import type { NationOp } from './protocol'; /** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */ export interface NationStats { @@ -133,6 +137,21 @@ export interface GameState { * this defaulted to `[]` (see server/db.ts). */ unowned: string[]; + /** + * (v0.23, #16 P4a) Nation-management ops the LLM has proposed but the player + * has not yet resolved: a *pending proposal* list, as opposed to `pending` + * (the current round's queued player actions). A turn NEVER applies one — + * `applyTurnResult` appends the turn's `nationOps` to this list, and each + * proposal survives across turns until the player resolves it: confirming + * applies it (a rename changes `Nation.name` only — the id, its territory, + * its relations, and the player pointer all stay valid) and removes it; + * rejecting removes it without applying. Split/merge are declared for P4b + * and are a safe no-op on confirm until then. Always `[]` on a fresh game; + * rows saved before v0.23 load with this defaulted to `[]` (see server/db.ts). + * (The `NationOp` type lives in protocol.ts; the import there is type-only, + * so this file still has no runtime dependencies.) + */ + pendingNationOps: NationOp[]; } /** diff --git a/src/App.tsx b/src/App.tsx index 979f100..cb9bd0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -177,6 +177,10 @@ export default function App() { // gate the turn flow ("Jump forward" stays enabled while the advisor thinks) — // it only gates the advisor UI (the "Ask advisor" button + its hint). const [advisorBusy, setAdvisorBusy] = useState(false); + // Separate again: a pending nation-op confirm/reject is its own (fast, + // non-LLM) request. It gates only the nation-op dialog controls, never the + // turn flow — "Jump forward" stays usable while it is in flight. + const [opBusy, setOpBusy] = useState(false); // The id of the game the user is/was last in (mirrors localStorage) and the // list of saved games (newest first) shown on the home screen. const [activeGameId, setActiveGameId] = useState(() => readActiveGameId()); @@ -401,6 +405,43 @@ export default function App() { [game, applyState], ); + // Apply a pending nation op the model proposed (confirm, with an optional + // rename-name edit). Fast, non-LLM — gated by `opBusy`, not `busy`. The + // version-bumped state comes back in the response and the SSE `state` event. + const runConfirmNationOp = useCallback( + async (index: number, edits?: { name?: string }) => { + if (!game) return; + setOpBusy(true); + try { + const { game: g } = await api.confirmNationOp(game.id, index, edits); + applyState(g); + toast.success('Nation change applied'); + } catch (e) { + toastError(e); + } finally { + setOpBusy(false); + } + }, + [game, applyState], + ); + + // Drop a pending nation op (reject / dismiss). Fast, non-LLM — `opBusy`. + const runRejectNationOp = useCallback( + async (index: number) => { + if (!game) return; + setOpBusy(true); + try { + const { game: g } = await api.rejectNationOp(game.id, index); + applyState(g); + } catch (e) { + toastError(e); + } finally { + setOpBusy(false); + } + }, + [game, applyState], + ); + // Jump forward: the single LLM call that commits the pending round. Only this // sets `busy` (the adds above are fast and non-LLM). const runJump = useCallback(async () => { @@ -507,12 +548,15 @@ export default function App() { regions={regions} busy={busy} advisorBusy={advisorBusy} + opBusy={opBusy} advisors={advisors} onAddAction={runAddAction} onAddDiplomacy={runAddDiplomacy} onRemoveQueueItem={runRemoveQueueItem} onAdvisor={askAdvisor} onJump={runJump} + onConfirmNationOp={runConfirmNationOp} + onRejectNationOp={runRejectNationOp} onHome={goHome} /> @@ -551,12 +595,16 @@ interface GameScreenProps { busy: boolean; /** An advisor fetch is in flight — drives the advisor UI only (never the turn). */ advisorBusy: boolean; + /** A nation-op confirm/reject is in flight — drives the dialog controls only. */ + opBusy: boolean; advisors: string[]; onAddAction: (a: string) => void; onAddDiplomacy: (to: string, m: string) => void; onRemoveQueueItem: (index: number) => void; onAdvisor: (p?: string) => void; onJump: () => void; + onConfirmNationOp: (index: number, edits?: { name?: string }) => void; + onRejectNationOp: (index: number) => void; onHome: () => void; } @@ -579,12 +627,15 @@ function GameScreen({ regions, busy, advisorBusy, + opBusy, advisors, onAddAction, onAddDiplomacy, onRemoveQueueItem, onAdvisor, onJump, + onConfirmNationOp, + onRejectNationOp, onHome, }: GameScreenProps) { // Which nation's dossier to show. Defaults to the player; remounts per game. @@ -835,12 +886,15 @@ function GameScreen({ game={game} busy={busy} advisorBusy={advisorBusy} + opBusy={opBusy} advisors={advisors} onAddAction={onAddAction} onAddDiplomacy={onAddDiplomacy} onRemoveQueueItem={onRemoveQueueItem} onAdvisor={onAdvisor} onJump={onJump} + onConfirmNationOp={onConfirmNationOp} + onRejectNationOp={onRejectNationOp} inWindow /> diff --git a/src/components/ActionPanel.tsx b/src/components/ActionPanel.tsx index 18e3b43..b614631 100644 --- a/src/components/ActionPanel.tsx +++ b/src/components/ActionPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react'; +import { Loader2, MessageSquare, PenLine, Radar, Trash2, Zap } from 'lucide-react'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; @@ -15,6 +15,7 @@ import { Input } from './ui/input'; import { Textarea } from './ui/textarea'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import NationOpDialog from './NationOpDialog'; import type { GameState, RoundItem } from '@shared/types'; interface Props { @@ -27,6 +28,11 @@ interface Props { * never disable "Jump forward" or the rest of the turn flow. */ advisorBusy: boolean; + /** + * A pending nation-op confirm/reject request is in flight. Distinct from + * `busy`: it gates only the nation-op dialog controls, never the turn flow. + */ + opBusy: boolean; /** Advisor brainstorm suggestions from the last request. */ advisors: string[]; /** Queue a free-text action onto the pending round (no turn advance). */ @@ -38,6 +44,10 @@ interface Props { onAdvisor: (prompt?: string) => void; /** Commit the whole pending round in a single LLM turn. */ onJump: () => void; + /** Apply a pending nation op; `edits.name` optionally overrides a rename. */ + onConfirmNationOp: (index: number, edits?: { name?: string }) => void; + /** Drop (reject) a pending nation op by its index. */ + onRejectNationOp: (index: number) => void; /** * Render without the outer card chrome (the surrounding floating window * provides the border and the "Your move" title). Same content, same testids. @@ -66,12 +76,15 @@ export default function ActionPanel({ game, busy, advisorBusy, + opBusy, advisors, onAddAction, onAddDiplomacy, onRemoveQueueItem, onAdvisor, onJump, + onConfirmNationOp, + onRejectNationOp, inWindow = false, }: Props) { const [tab, setTab] = useState('action'); @@ -81,6 +94,8 @@ export default function ActionPanel({ const [prompt, setPrompt] = useState(''); // Index of the queued item the user is about to remove, or null (dialog closed). const [confirmRemoveIndex, setConfirmRemoveIndex] = useState(null); + // Whether the nation-op review dialog is open. + const [opDialogOpen, setOpDialogOpen] = useState(false); const others = Object.entries(game.nations) .filter(([id]) => id !== game.playerNationId) @@ -108,6 +123,7 @@ export default function ActionPanel({ }; const pending = game.pending; + const ops = game.pendingNationOps; // The item the confirmation dialog is about (re-derived from the live list, // so if the queue changes while the dialog is open it tracks the current // item at that index — or the dialog closes if the index went out of range). @@ -325,6 +341,31 @@ export default function ActionPanel({ + {/* Pending nation ops proposed by the model — open to confirm or reject. */} + {ops.length > 0 && ( + + )} + + + + + + ) : ( +
+ + {op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it. + + +
+ )} + + ); + })} + + )} + + + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index be6a0c1..f60af69 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,9 +6,11 @@ import type { AddActionResponse, AddDiplomacyResponse, AdvisorResponse, + ConfirmNationOpResponse, CreateGameResponse, DeleteGameResponse, GameSummary, + RejectNationOpResponse, RemoveQueueItemResponse, TurnResponse, } from '@shared/protocol'; @@ -60,6 +62,21 @@ export const api = { method: 'POST', body: JSON.stringify({ index }), }), + // (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in + // `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the + // proposed name). Fast, non-LLM — no turn advance. Returns the updated game. + confirmNationOp: (id: string, index: number, edits?: { name?: string }) => + req(`/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(`/api/games/${id}/nation-op/reject`, { + method: 'POST', + body: JSON.stringify({ index }), + }), // Commit the whole pending round (or an empty one) in a single LLM turn. jump: (id: string) => req(`/api/games/${id}/jump`, {