From 8730a561706819b5713fcb7de21ae137c79dc567 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 20:09:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(#61):=20named=20leaders=20=E2=80=94=20assa?= =?UTF-8?q?ssination=20/=20death=20/=20appointment=20with=20succession=20(?= =?UTF-8?q?v0.38.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-nation named leaders (Nation.leader) and a required TurnResult.leaders lever ({ changes: [{ target, name, cause }] }). Each change sets the target nation's leader and applies a deterministic stability dip by cause (assassination -15, death -3, appointed 0) mirrored to its regions and re-derived into its stats, plus a narration event (military for an assassination, political for a death/appointment). An assassination/death requires the target to already have a leader (a leaderless target is dropped); an appointment always creates/sets one. Unknown/ambiguous targets are dropped, never thrown. - shared/types.ts: optional Nation.leader - shared/protocol.ts: LeaderCause/LeaderChange/LeaderOps, LEADER_STABILITY_EFFECT, TurnResult.leaders (required, between nuclear and markers), leader in nationForPrompt - server/engine.ts: validateLeaderOps, applyLeaderChange, the applyTurnResult leader block (after nuclear, before markers), isMeaningful, system-prompt contract + constraint, validateTurnResult + runTurn fallback - server/stub.ts: leaders always { changes: [] } (e2e unaffected) - tests: 15 engine.test.ts leader tests + 2 db.test.ts round-trips - version 0.37.0 -> 0.38.0, CHANGELOG entry --- CHANGELOG.md | 16 +++ package.json | 2 +- server/db.test.ts | 22 ++++ server/engine.test.ts | 254 +++++++++++++++++++++++++++++++++++------- server/engine.ts | 139 +++++++++++++++++++++++ server/stub.ts | 11 +- shared/protocol.ts | 58 ++++++++++ shared/types.ts | 8 ++ 8 files changed, 469 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1380693..af91ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to **History** are listed here, newest first. +## v0.38.0 — 2026-10-15 20:06 +- **#61: Named leaders — a per-nation `leader` + a `TurnResult.leaders` lever (assassination / death / appointment) with a deterministic stability effect and succession narration.** Each nation may carry a single named head of state (`Nation.leader`); the model changes it each turn via `leaders.changes`. The engine is **fully deterministic (no hidden randomness)** and **additive** (no `db.ts` change — `leader` is an optional `Nation` field; a pre-v0.38 row loads with it absent, i.e. "no leader"). + - **Types (`shared/types.ts`).** `Nation.leader?: { name: string }` (optional; `undefined`/absent = the nation has no leader). + - **Protocol (`shared/protocol.ts`).** New `LeaderCause` (`'assassination' | 'death' | 'appointed'`), `LeaderChange` (`{ target; name; cause }` — `target` is a nation ref, `name` is the new leader's name), and `LeaderOps` (`{ changes: LeaderChange[] }`); `TurnResult.leaders` is **required**, placed between `nuclear` and `markers`, default `{ changes: [] }`. New exported **`LEADER_STABILITY_EFFECT = { assassination: −15, death: −3, appointed: 0 }`** (the deterministic stability dip by cause). `nationForPrompt` now includes the nation's `leader` (omitted when absent), so `buildUserPrompt` carries each nation's current leader with no separate wiring. + - **Engine (`server/engine.ts`).** + - `validateLeaderOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (≤120), a non-empty `name` (≤120), and a `cause` that is one of the three literals — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear ops). + - `applyLeaderChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyHybridOp` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). **GATE**: an `assassination`/`death` requires the target to already have a leader (a leaderless target is dropped); `appointed` is always valid (it creates the first leader). On a valid change it sets `n.leader = { name }` and applies `LEADER_STABILITY_EFFECT[cause]` to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry, so a 0-region nation keeps its directly-mutated value). Finally it appends a narration event: a dramatic **`military`** event for an assassination, a **`political`** succession event for a death or an appointment. The replacement never moves territory (sole ownership is untouched). + - `applyTurnResult` — the leader block runs **after `nuclear` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → **leaders** → markers → nationOps). + - `isMeaningful` — a non-empty `leaders.changes` makes a turn meaningful. + - `validateTurnResult` — initializes and validates `leaders` (default `{ changes: [] }`). + - `buildSystemPrompt` — documents the `leaders` field (the three causes, the stability effects, the leaderless-gate, the "at most 1–2 per turn, narrated" rule) and contrasts it with the lighter `hybrid.assassinate` op (a −5 dip, no leader replacement) in both tool and plain modes. + - `runTurn` — the "unclear" fallback carries `leaders: { changes: [] }`. + - **Stub (`server/stub.ts`).** `deterministicTurn` always returns `leaders: { changes: [] }` and the `StubPayload` nations type gains an optional `leader` — the stub **never** changes a leader, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only. + - **Tests.** New unit tests in `engine.test.ts`: `validateLeaderOps` (`{changes}` object, bare array, unknown-cause drop, missing target/name drop, malformed top-level, mixed batch keeps only valid); `isMeaningful` (a leader change is meaningful, empty leaders is not); `applyTurnResult` (assassination with a leader → replaced + −15 + `military` event; assassination / death on a leaderless nation → dropped, no stat change, no event; death → replaced + −3 + `political` succession event; appointment on a leader-having and on a leaderless nation → sets the leader, neutral (no dip); a multi-region nation → every region dipped and stats re-derived; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing). Two `db.test.ts` round-trips: a legacy row with no leader loads with it absent, and a set leader round-trips through save/load. +- **Version** bumped `0.37.0 → 0.38.0`. + ## v0.37.0 — 2026-10-15 19:15 - **#60: Nuclear / WMD — a per-nation `nuclear` capability flag + a `TurnResult.nuclear` lever (`arm` + `strikes`) with deterministic strikes and MAD retaliation.** A nation is *nuclear-capable* when its `nuclear` flag is `true` **or** it currently owns a `rocket` marker (a launch site is an arsenal). The model can **arm** nations (grant the flag — idempotent; a newly-armed nation is capable the same turn, since `arm` is applied before `strikes`) and fire **strikes** (`{ from, target }`). The engine is **fully deterministic (no hidden randomness)** and **additive** (a `db.ts` change is not needed — `nuclear` is an optional `Nation` field; a pre-v0.37 row loads with the flag absent). - **Capability (`shared/protocol.ts`).** `isNuclearCapable(nation, markers)` — exported pure helper: `nation.nuclear === true` **or** it owns a `rocket` marker (`marker.type === 'rocket' && marker.ownerNationId === nation.id`). Markers supply the *pre-turn* rocket set, so a rocket placed this turn makes the owner capable from the *next* turn. diff --git a/package.json b/package.json index f21126b..b626dbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.37.0", + "version": "0.38.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 4f0d09e..e63d61e 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -200,6 +200,28 @@ describe('openDb', () => { db.close(); }); + it('(v0.38.0 #61) a legacy row saved before the nation leader field existed loads with it absent (no leader)', () => { + const db = openDb(':memory:'); + // makeState has no nation.leader field — a pre-v0.38 save. The field is + // optional and not re-stamped by load, so an absent leader stays absent. + db.save(makeState('legacy')); + const loaded = db.load('legacy'); + expect(loaded).not.toBeNull(); + expect(loaded!.nations.uk.leader).toBeUndefined(); + db.close(); + }); + + it('(v0.38.0 #61) a set nation leader round-trips through save/load', () => { + const db = openDb(':memory:'); + const s = makeState('ldr'); + s.nations.uk.leader = { name: 'Admiral Old' }; + db.save(s); + const loaded = db.load('ldr'); + expect(loaded).not.toBeNull(); + expect(loaded!.nations.uk.leader).toEqual({ name: 'Admiral Old' }); + 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/engine.test.ts b/server/engine.test.ts index a0f5b7a..12ebdc6 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -48,6 +48,7 @@ import { validateEconomyOps, validateHybridOps, validateIndustryOps, + validateLeaderOps, validateMarkerOps, validateNuclearOps, validateNationOps, @@ -568,7 +569,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -590,7 +591,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -611,7 +612,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -630,7 +631,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1087,7 +1088,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1104,7 +1105,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1127,7 +1128,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; 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 @@ -1153,7 +1154,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1171,7 +1172,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1261,7 +1262,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1376,7 +1377,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: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1683,9 +1684,9 @@ 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: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1698,7 +1699,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1718,7 +1719,7 @@ describe('isMeaningful', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); @@ -1739,7 +1740,7 @@ describe('isMeaningful', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -1762,7 +1763,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1795,7 +1796,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1819,7 +1820,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1846,7 +1847,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1872,7 +1873,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); @@ -1898,7 +1899,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1922,7 +1923,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1944,7 +1945,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1978,7 +1979,7 @@ describe('markers (point placement, v0.27 #44)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); @@ -2003,13 +2004,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -2035,7 +2036,7 @@ describe('markers (point placement, v0.27 #44)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept @@ -2082,7 +2083,7 @@ describe('markers (point placement, v0.27 #44)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, @@ -2108,7 +2109,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2124,14 +2125,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2200,7 +2201,7 @@ describe('markers (point placement, v0.27 #44)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ @@ -2295,6 +2296,7 @@ function mkTurnResult(partial: Partial): TurnResult { economy: partial.economy ?? { trade: [], embargo: [] }, industry: partial.industry ?? { projects: [] }, nuclear: partial.nuclear ?? { arm: [], strikes: [] }, + leaders: partial.leaders ?? { changes: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3019,7 +3021,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); @@ -3407,7 +3409,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { wars: { declare: [{ sides: ['a', 'b'] }], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3428,7 +3430,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3449,7 +3451,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, - nuclear: { arm: [], strikes: [] }, + nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); @@ -4611,7 +4613,7 @@ describe('isMeaningful — nuclear (v0.37.0 #60)', () => { expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] } }))).toBe(true); }); it('a turn with empty nuclear ops and nothing else is not meaningful', () => { - expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] } }))).toBe(false); + expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] }, leaders: { changes: [] } }))).toBe(false); }); }); @@ -4644,3 +4646,177 @@ describe('buildUserPrompt — nuclear capability (v0.37.0 #60)', () => { expect(out.state.nations.de).not.toHaveProperty('nuclear'); }); }); + +describe('named leaders (v0.38.0 #61)', () => { + // `uk` starts with a named leader; `de` has none. Each owns one region + // seeded to its stats (the fresh-state invariant), so a uniform dip leaves + // the derived median equal to the dipped value. + const leaderBase = (): GameState => { + const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), leader: { name: 'Admiral Old' } }; + const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) }; + return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) }); + }; + + describe('validateLeaderOps', () => { + it('accepts a {changes} object and a bare array of valid changes', () => { + const fromObj = validateLeaderOps({ + changes: [ + { target: 'uk', name: 'New Leader', cause: 'assassination' }, + { target: 'de', name: 'Other', cause: 'death' }, + ], + }); + expect(fromObj).toEqual([ + { target: 'uk', name: 'New Leader', cause: 'assassination' }, + { target: 'de', name: 'Other', cause: 'death' }, + ]); + expect(validateLeaderOps([{ target: 'uk', name: 'X', cause: 'appointed' }])).toEqual([ + { target: 'uk', name: 'X', cause: 'appointed' }, + ]); + }); + + it('drops a change with an unknown cause', () => { + expect(validateLeaderOps({ changes: [{ target: 'uk', name: 'X', cause: 'explosion' }] })).toEqual([]); + }); + + it('drops a change missing a target or a name', () => { + expect( + validateLeaderOps({ + changes: [ + { target: '', name: 'X', cause: 'appointed' }, + { target: 'uk', name: '', cause: 'death' }, + ], + }), + ).toEqual([]); + }); + + it('returns [] for undefined / non-object / non-array-changes input', () => { + expect(validateLeaderOps(undefined)).toEqual([]); + expect(validateLeaderOps('nope')).toEqual([]); + expect(validateLeaderOps({ changes: 'nope' })).toEqual([]); + }); + + it('keeps only the valid changes of a mixed batch', () => { + const out = validateLeaderOps({ + changes: [ + { target: 'uk', name: 'Good', cause: 'appointed' }, + { target: 'uk', name: 'Bad', cause: 'nope' }, + null, + { target: 'uk', name: 'Also Good', cause: 'death' }, + ], + }); + expect(out).toEqual([ + { target: 'uk', name: 'Good', cause: 'appointed' }, + { target: 'uk', name: 'Also Good', cause: 'death' }, + ]); + }); + }); + + describe('isMeaningful — leaders', () => { + it('a turn with only a leader change is meaningful', () => { + expect(isMeaningful(mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'X', cause: 'death' }] } }))).toBe(true); + }); + it('a turn with empty leaders and nothing else is not meaningful', () => { + expect(isMeaningful(mkTurnResult({ leaders: { changes: [] } }))).toBe(false); + }); + }); + + describe('applyTurnResult — leaders', () => { + it('assassinating a nation with a leader replaces it, dips stability, and adds a military event', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New Leader', cause: 'assassination' }] } }), + ); + expect(next.nations.uk.leader).toEqual({ name: 'New Leader' }); + expect(next.nations.uk.stats.stability).toBe(35); // 50 - 15 + expect(next.regionStats.g1.stability).toBe(35); // mirrored to the region + expect(next.events).toHaveLength(1); + expect(next.events[0]!.type).toBe('military'); + }); + + it('assassinating a leaderless nation is dropped (no leader, no stat change, no event)', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'New Leader', cause: 'assassination' }] } }), + ); + expect(next.nations.de.leader).toBeUndefined(); + expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50)); + expect(next.events).toHaveLength(0); + }); + + it('a death of a leader replaces it, dips stability a little, and adds a political succession event', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'Successor', cause: 'death' }] } }), + ); + expect(next.nations.uk.leader).toEqual({ name: 'Successor' }); + expect(next.nations.uk.stats.stability).toBe(47); // 50 - 3 + expect(next.events).toHaveLength(1); + expect(next.events[0]!.type).toBe('political'); + }); + + it('a death of a leaderless nation is dropped', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'X', cause: 'death' }] } }), + ); + expect(next.nations.de.leader).toBeUndefined(); + expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50)); + expect(next.events).toHaveLength(0); + }); + + it('an appointment on a nation that has a leader replaces it with no stability dip', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New PM', cause: 'appointed' }] } }), + ); + expect(next.nations.uk.leader).toEqual({ name: 'New PM' }); + expect(next.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // neutral: no dip + expect(next.events).toHaveLength(1); + expect(next.events[0]!.type).toBe('political'); + }); + + it('an appointment creates the first leader on a leaderless nation', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'First', cause: 'appointed' }] } }), + ); + expect(next.nations.de.leader).toEqual({ name: 'First' }); + expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50)); + }); + + it('a leader change on a multi-region nation dips every region and re-derives its stats', () => { + const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), leader: { name: 'Old' } }; + const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) }); + const next = applyTurnResult( + state, + mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New', cause: 'assassination' }] } }), + ); + expect(next.regionStats.g1.stability).toBe(35); + expect(next.regionStats.g2.stability).toBe(35); + expect(next.nations.uk.stats.stability).toBe(35); // re-derived from the dipped regions + }); + + it('an unknown target is dropped without throwing (no leader change, no event, no crash)', () => { + const next = applyTurnResult( + leaderBase(), + mkTurnResult({ leaders: { changes: [{ target: 'ghost', name: 'X', cause: 'assassination' }] } }), + ); + expect(next.nations.uk.leader).toEqual({ name: 'Admiral Old' }); // untouched + expect(next.events).toHaveLength(0); + }); + + it('an ambiguous target (two nations share the name) is dropped without throwing', () => { + const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same', leader: { name: 'Old' } }; + const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' }; + const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) }); + const next = applyTurnResult( + state, + mkTurnResult({ leaders: { changes: [{ target: 'Same', name: 'New', cause: 'assassination' }] } }), + ); + // 'Same' matches two nations -> ambiguous -> dropped; neither leader changes. + expect(next.nations.a.leader).toEqual({ name: 'Old' }); + expect(next.nations.b.leader).toBeUndefined(); + expect(next.events).toHaveLength(0); + }); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index f364176..4cbc013 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -34,6 +34,7 @@ import { isDateLike, isMarkerType, isNuclearCapable, + LEADER_STABILITY_EFFECT, makeEvent, markerId, MAX_MARKERS, @@ -46,6 +47,8 @@ import { type HybridOp, type HybridOpType, type IndustryOps, + type LeaderCause, + type LeaderChange, type MarkerInfo, type MarkerSpec, type NationOp, @@ -243,6 +246,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' "economy": {"trade":[{"a":"","b":"","lift":}],"embargo":[{"from":"","target":"","lift":}]},', ' "industry": {"projects":[{"regionKey":"","type":""}]},', ' "nuclear": {"arm":[""],"strikes":[{"from":"","target":""}]},', + ' "leaders": {"changes":[{"target":"","name":"","cause":""}]},', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -266,6 +270,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.', '- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"","type":""} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".', '- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".', + '- Leaders ("leaders") name each nation\'s current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message\'s "nations" field lists each nation\'s current "leader" (omitted when a nation has none).', '- 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.', @@ -631,6 +636,7 @@ export function validateTurnResult( economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, + leaders: { changes: [] }, markers: { add: [], remove: [] }, }; @@ -757,6 +763,13 @@ export function validateTurnResult( // (after industry, before markers). result.nuclear = validateNuclearOps(o.nuclear); + // (v0.38, #61) Leader changes: assassinations / deaths / appointments of + // named leaders. Refs stay raw here (resolved against the real state at + // apply time, like wars / hybrid / nuclear ops); an unresolvable target or a + // leaderless assassination/death is dropped at apply time, never thrown. + // Applied by applyTurnResult after nuclear, before markers. + result.leaders = { changes: validateLeaderOps(o.leaders) }; + // (v0.27, #44) Map-marker ops: new placements (validated here) and // removals by stable id. Applied by applyTurnResult after the territory // moves (a new marker's owner is the region's post-turn owner). @@ -1093,6 +1106,39 @@ export function validateNuclearOps(raw: unknown): NuclearOps { return out; } +/** + * (v0.38, #61) Validate the raw `leaders` object of a turn into a safe + * `LeaderChange[]`. Defensive like the rest of the parser — never throws. + * Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change + * needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty + * `name` (the new leader's name, capped at 120 chars), and a `cause` that is + * one of the three known literals (assassination / death / appointed). Refs + * stay raw here (resolved against the real state at apply time, like the wars + * / hybrid / nuclear ops); a leaderless assassination/death is dropped at + * apply time, never thrown. + */ +export function validateLeaderOps(raw: unknown): LeaderChange[] { + const out: LeaderChange[] = []; + const list = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' + ? (raw as Record).changes + : undefined; + if (!Array.isArray(list)) return out; + for (const e of list) { + if (!e || typeof e !== 'object') continue; + const s = e as Record; + const target = str(s.target, 120); + const name = str(s.name, 120); + if (!target || !name) continue; + const cause = typeof s.cause === 'string' ? s.cause : ''; + if (cause === 'assassination' || cause === 'death' || cause === 'appointed') { + out.push({ target, name, cause: cause as LeaderCause }); + } + } + return out; +} + /** * (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE * wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c}, @@ -1377,6 +1423,85 @@ function applyHybridOp( else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival'); } +// --- Named leaders (v0.38, #61) ----------------------------------------------- + +/** + * (v0.38, #61) Apply a single leader change to `next` (in place). The target + * nation is resolved by the shared resolver (id first, then a case-insensitive + * unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE + * then applies: an `assassination` or `death` requires the target to already + * have a leader (a leaderless target is dropped); an `appointed` change is + * always valid (it creates the first leader). On a valid change the target's + * `leader` is set to the new name and a DETERMINISTIC stability dip + * (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability` + * (clamped 0–100) and mirrored to every region the nation owns + * (`regionStats`); the nation's derived stats are re-derived afterwards (the + * same 0-region / no-entry guards as the hybrid/economy/industry effects, so a + * 0-region nation keeps its directly-mutated value). Finally a narration event + * is appended: a dramatic `military` event for an assassination, a `political` + * succession event for a death or an appointment. The replacement never moves + * territory (sole ownership is untouched). + */ +function applyLeaderChange( + next: GameState, + op: LeaderChange, + geoOf?: (key: string) => { area: number; population: number } | undefined, + actingTurn?: number, +): void { + const turn = actingTurn ?? next.turn; + const id = resolveNationRef(next, op.target); + if (id === null) return; // unknown / ambiguous target: drop, never throw + const n = next.nations[id]; + + // GATE: assassination / death require an existing leader; an appointment is + // always valid (it creates the first leader). + if (op.cause !== 'appointed' && !n.leader) return; + + const prevName = n.leader?.name; + n.leader = { name: op.name }; + + const delta = LEADER_STABILITY_EFFECT[op.cause]; + if (delta !== 0) { + n.stats.stability = clampStat(n.stats.stability + delta); + for (const key of n.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; + rs.stability = clampStat(rs.stability + delta); + } + // Re-derive the nation's aggregate stats (the main recompute ran earlier + // this turn, so without this the dip would be washed out next turn). + if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } + } + + const title = + op.cause === 'assassination' + ? `${prevName ?? 'The leader'} of ${n.name} is assassinated` + : op.cause === 'death' + ? `${prevName ?? 'The leader'} of ${n.name} dies` + : `${n.name} appoints ${op.name}`; + const text = + op.cause === 'assassination' + ? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.` + : op.cause === 'death' + ? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.` + : `${n.name} has appointed ${op.name} as its new leader.`; + next.events.push( + makeEvent( + { + nation: n.id, + type: op.cause === 'assassination' ? 'military' : 'political', + title, + text, + outcome: 'success', + }, + turn, + next.date, + ), + ); +} + // --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------ /** @@ -1948,6 +2073,9 @@ export function isMeaningful(result: TurnResult): boolean { // this turn is a real world change. result.nuclear.arm.length > 0 || result.nuclear.strikes.length > 0 || + // (v0.38, #61) a leader assassinated / died / appointed this turn is a real + // world change (a nation's leader and stability moved). + result.leaders.changes.length > 0 || // (v0.27, #44) a marker placed or removed this turn is a real world change. result.markers.add.length > 0 || result.markers.remove.length > 0 @@ -2372,6 +2500,16 @@ export function applyTurnResult( // rocket-marker half of nuclear capability. applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn); + // --- Named leaders (v0.38, #61) --- + // + // Placed AFTER the nuclear block (both are the "heavier" levers) and BEFORE + // the markers block (markers recompute their owner from the FINAL post-claim + // ownership; leaders only touch stats, so ordering w.r.t. markers is + // immaterial, but keeping the levers grouped is clearer). Each change sets + // the target's leader, applies a deterministic stability dip by cause, and + // appends a narration event. A leaderless assassination/death is dropped. + for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn); + // --- Map markers (v0.27, #44) --- // // Markers are anchored to a region key and are processed against the FINAL @@ -2983,6 +3121,7 @@ export async function runTurn(opts: { economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, + leaders: { changes: [] }, markers: { add: [], remove: [] }, }; } diff --git a/server/stub.ts b/server/stub.ts index 1ee221b..ea60a1d 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -16,9 +16,17 @@ export interface StubPayload { // `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by // the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores // it — it never arms or strikes (see deterministicTurn). + // `leader` (v0.38.0, #61) is the per-nation named leader stamped by the + // engine; the stub ignores it — it never changes a leader (see + // deterministicTurn). nations: Record< string, - { stats: Record; territory?: string[]; nuclear?: boolean } + { + stats: Record; + territory?: string[]; + nuclear?: boolean; + leader?: { name: string }; + } >; }; /** The raw pending round the engine emitted (one entry per queued item). */ @@ -224,6 +232,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment) industry: { projects: [] }, // the stub never declares industry projects (see doc comment) nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment) + leaders: { changes: [] }, // the stub never changes leaders (see doc comment) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index 091cb85..e4998c2 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -468,6 +468,48 @@ export interface NuclearOps { strikes: NuclearStrike[]; } +// --- Named leaders (v0.38, #61) ----------------------------------------------- +// +// Each nation may have a single named leader (`Nation.leader`). The LLM changes +// it each turn via `TurnResult.leaders`. A change names a target nation (id or +// name) and the outcome: an `assassination` (a leader killed — a dramatic, +// destabilizing blow), a `death` (a leader dies of natural causes — a quieter +// succession), or an `appointed` leader (a neutral succession / new head of +// state). The engine sets the nation's leader to the new name and applies a +// DETERMINISTIC stability dip (see `LEADER_STABILITY_EFFECT` below) — no hidden +// randomness. A change whose target has no current leader may only *appoint* +// one (an assassination/death of a leaderless nation is dropped, not thrown). + +/** How a nation's leader changed this turn (v0.38, #61). */ +export type LeaderCause = 'assassination' | 'death' | 'appointed'; + +/** A single leader change: the target nation, the new leader's name, the cause. */ +export interface LeaderChange { + /** The target nation, by id or name (resolved by the shared resolver). */ + target: string; + /** The new leader's name (the incoming / successor head of state). */ + name: string; + /** What happened to the leader this turn. */ + cause: LeaderCause; +} + +/** The leader-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */ +export interface LeaderOps { + changes: LeaderChange[]; +} + +/** + * The deterministic stability effect of each leader-cause (v0.38, #61). + * Assassination is the most destabilizing, a natural death a minor wobble, an + * appointment is neutral. Applied to the target's `stability` (clamped 0–100), + * mirrored to its `regionStats`, and its derived stats re-derived afterwards. + */ +export const LEADER_STABILITY_EFFECT: Record = { + assassination: -15, + death: -3, + appointed: 0, +}; + // --- Nuclear / WMD effect math (v0.37, #60) ---------------------------------- // // A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no @@ -637,6 +679,21 @@ export interface TurnResult { * never queued. Required; default `{ arm: [], strikes: [] }`. */ nuclear: NuclearOps; + /** + * (v0.38, #61) Leader changes this turn. Each change names a target nation + * (id or name) and sets its leader to the new name, applying a deterministic + * stability effect by cause: `assassination` (leader killed — a dramatic + * −15 to the target's stability, a `military` event), `death` (natural + * causes — a quieter −3 succession, a `political` event), or `appointed` + * (a neutral 0 succession, a `political` event). An assassination/death + * requires the target to already have a leader (a leaderless target is + * dropped, never thrown); an appointment is always valid (it creates the + * first leader). At most 1–2 per turn, each narrated in `events`. This is + * the *leader* lever — heavier and more destabilizing than the lighter + * `hybrid.assassinate` op (a −5 stability dip on a nation or region, with + * no leader replacement). Required; default `{ changes: [] }`. + */ + leaders: LeaderOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small @@ -949,6 +1006,7 @@ export function nationForPrompt(n: Nation) { stats: n.stats, territory: n.territory, relations: n.relations, + leader: n.leader, }; } diff --git a/shared/types.ts b/shared/types.ts index 7494f09..155166c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -80,6 +80,14 @@ export interface Nation { * `isNuclearCapable` in `protocol.ts`). */ nuclear?: boolean; + /** + * The nation's current named leader (v0.38, #61). Optional — a legacy game + * (or a nation the LLM has never given a leader to) has none, which is the + * "no leader" baseline. The LLM changes it via `TurnResult.leaders` + * (assassination / death / appointment, see `LeaderChange` in `protocol.ts`); + * each change carries a deterministic stability effect + a narration event. + */ + leader?: { name: string }; } /** Categories a narrative event can take. */