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

    + Choose which new state you will lead after the split. +

    + )} + {(op as { parts: unknown[] }).parts.length < 2 && ( +

    + This split names fewer than two parts — confirming will dismiss it + without changing the world. +

    + )} +
    + +
    ) : ( -
    - - {op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it. - - +
    +
    + {(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => { + const isSurvivor = resolveRef(ref) === survivorId; + return ( + + {name(ref)} + {isSurvivor ? ' (survivor)' : ''} + + ); + })} +
    + setEdits((p) => ({ ...p, [i]: e.target.value }))} + placeholder="New name…" + data-testid="nation-op-name-input" + /> + {!mergeValid && ( +

    + This merge is not valid as proposed — confirming will dismiss it + without changing the world. +

    + )} +
    + + +
    )}
  • diff --git a/src/lib/api.ts b/src/lib/api.ts index f60af69..9d6bcb0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -62,10 +62,13 @@ export const api = { method: 'POST', body: JSON.stringify({ index }), }), - // (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in - // `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the - // proposed name). Fast, non-LLM — no turn advance. Returns the updated game. - confirmNationOp: (id: string, index: number, edits?: { name?: string }) => + // (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by + // its index in `game.pendingNationOps`: a rename takes effect (`edits.name` + // overrides the proposed name), a split partitions the nation's territory + // (`edits.playerPart` picks which part the player leads, default 0), and a + // merge folds its inputs into the survivor (`edits.name` overrides the + // merged name). Fast, non-LLM — no turn advance. Returns the updated game. + confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) => req(`/api/games/${id}/nation-op/confirm`, { method: 'POST', body: JSON.stringify({ index, ...(edits ? { edits } : {}) }), -- 2.54.0 From 7eaed42d5157366d755aee87a5f03ccc66c7d8dc Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 13:42:46 +0000 Subject: [PATCH 3/4] chore: bump to v0.24.0 (nation-op split/merge, #16 P4b) + CHANGELOG --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a7656..e64542d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ 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).** This completes #16: `Fixes #16` on the merged PR closes the issue. + ## 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. diff --git a/package.json b/package.json index 742f17c..f18dd15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.23.0", + "version": "0.24.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", -- 2.54.0 From c0c620a54c0eb6fb5f3e5f020676cf81bab22de7 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 13:46:16 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20v0.24.0=20changelog=20=E2=80=94=20#?= =?UTF-8?q?16=20was=20already=20closed;=20reference=20it=20(no=20Fixes),?= =?UTF-8?q?=20matching=20P4a's=20PR=20#38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64542d..35b5d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to **History** are listed here, newest first. - **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).** This completes #16: `Fixes #16` on the merged PR closes the issue. +- Part of #16 — major nation changes (P4b, the final part of the epic). #16 was already closed (2026-10-12, after P3 shipped); the PR references it for traceability the same way P4a's PR #38 did ("Part of #16" / "Related: #16") — no auto-close. ## v0.23.0 — 2026-10-14 12:58 - **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b. -- 2.54.0