From 7866a1987d0fab0b6860320e181b5e4b23ad3380 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 20:54:18 +0000 Subject: [PATCH] feat(#62): ideology / government-type change (v0.39.0) Add a per-nation free-form ideology (form of government) field and a TurnResult.ideology lever (coups / revolutions / reforms / constitutional changes) with a deterministic -5 stability dip and political narration. - shared/types.ts: Nation.ideology?: string (optional/additive) - shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT = -5, TurnResult.ideology (required, between leaders and markers), nationForPrompt now carries ideology - server/engine.ts: validateIdeologyOps (exported, never throws), applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions + re-derived -> political event), ideology block in applyTurnResult after leaders / before markers, isMeaningful + validateTurnResult + system prompt + runTurn unclear fallback - server/stub.ts: StubPayload gains ideology; deterministicTurn returns ideology: { changes: [] } (never changes one, e2e unaffected) - Tests: engine.test.ts (validate / isMeaningful / applyTurnResult / buildUserPrompt), protocol.test.ts (constant + nationForPrompt), db.test.ts (legacy load + round-trip); every TurnResult fixture updated - package.json -> 0.39.0; CHANGELOG v0.39.0 entry --- CHANGELOG.md | 16 +++ package.json | 2 +- server/db.test.ts | 22 ++++ server/engine.test.ts | 254 ++++++++++++++++++++++++++++++++++++---- server/engine.ts | 118 +++++++++++++++++++ server/protocol.test.ts | 29 +++++ server/stub.ts | 13 ++ shared/protocol.ts | 56 +++++++++ shared/types.ts | 12 ++ 9 files changed, 501 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af91ead..8d18565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to **History** are listed here, newest first. +## v0.39.0 — 2026-10-15 20:51 +- **#62: Ideology / form of government — a per-nation `ideology` + a `TurnResult.ideology` lever (coups / revolutions / reforms / constitutional changes) with a deterministic −5 stability dip and political narration.** Each nation may carry a free-form `ideology` label — its form of government ("democracy", "fascism", "military junta", …). The model changes it each turn via `ideology.changes`; each change names a target nation (id or name) and the new government type. The engine sets the label and applies a **deterministic** stability dip (a regime shift always unsettles a nation) — **no hidden randomness**, **additive** (no `db.ts` change — `ideology` is an optional `Nation` field; a pre-v0.39 row loads with it absent, i.e. "no recorded government"). It is a third, independent axis next to `leader` (who is in charge) and the v0.32.0 takeover (which renames the nation): the id, name, territory, relations, and the leader all stay valid — only the form of government and stability move. + - **Types (`shared/types.ts`).** `Nation.ideology?: string` (optional; `undefined`/absent = no recorded government). + - **Protocol (`shared/protocol.ts`).** New `IdeologyChange` (`{ target; to }` — `target` is a nation ref, `to` is the new form of government), `IdeologyOps` (`{ changes: IdeologyChange[] }`); `TurnResult.ideology` is **required**, placed between `leaders` and `markers`, default `{ changes: [] }`. New exported **`IDEOLOGY_STABILITY_EFFECT = −5`** (the deterministic stability dip of a regime shift). `nationForPrompt` now includes the nation's `ideology` (omitted when absent via `JSON.stringify`), so `buildUserPrompt` carries each nation's current government with no separate wiring. + - **Engine (`server/engine.ts`).** + - `validateIdeologyOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (trimmed, ≤120) and a non-empty, non-whitespace `to` (trimmed, ≤120) — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear / leader ops). + - `applyIdeologyChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyLeaderChange` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). On a valid change it sets `n.ideology = to` and applies `IDEOLOGY_STABILITY_EFFECT` (−5) 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 / leaders, so a 0-region nation keeps its directly-mutated value). Re-setting the **same** label is idempotent for the `ideology` field but still narrated and still dips (the shift just happened). Finally it appends a **`political`** narration event (`"'s government shifted to "`). The change never moves territory (sole ownership is untouched). + - `applyTurnResult` — the ideology block runs **after `leaders` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → leaders → **ideology** → markers → nationOps). + - `isMeaningful` — a non-empty `ideology.changes` makes a turn meaningful. + - `validateTurnResult` — initializes and validates `ideology` (default `{ changes: [] }`). + - `buildSystemPrompt` — documents the `ideology` field (free-form label, the −5 dip, the "at most 1–2 per turn, narrated" rule) and contrasts it with a takeover (which RENAMES the nation) and a leader change (who is in charge); similar forms of government are a **soft, prompt-level compatibility nudge only** (alike ideologies tend to ally/trade, opposed ones tend to clash) — there is no engine gate behind it. + - `runTurn` — the "unclear" fallback carries `ideology: { changes: [] }`. + - **Stub (`server/stub.ts`).** `deterministicTurn` always returns `ideology: { changes: [] }` and the `StubPayload` nations type gains an optional `ideology` — the stub **never** changes an ideology, 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`: `validateIdeologyOps` (`{changes}` object, bare array, empty/whitespace `to` drop, missing/whitespace target drop, malformed top-level, mixed batch keeps only valid, trim + 120-cap); `isMeaningful` (an ideology change is meaningful, empty ideology is not); `applyTurnResult` (a valid change → field set + 50→45 + `political` event + region mirror, other nation untouched; re-setting the same label → one dip, one event; a multi-region nation → every region dipped and stats re-derived; a target resolved by name as well as id; a 0-region nation keeps its directly-dipped stat; a dip clamped at 0; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing); `buildUserPrompt` (a set ideology is stamped in the prompt nations, an absent one is omitted). `protocol.test.ts`: the `IDEOLOGY_STABILITY_EFFECT` constant is −5 and `nationForPrompt` includes/omits the `ideology` key in the JSON. Two `db.test.ts` round-trips: a legacy row with no ideology loads with it absent, and a set ideology round-trips through save/load. +- **Version** bumped `0.38.0 → 0.39.0`. + ## 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). diff --git a/package.json b/package.json index b626dbc..b8573d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.38.0", + "version": "0.39.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 e63d61e..2faa001 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -222,6 +222,28 @@ describe('openDb', () => { db.close(); }); + it('(v0.39.0 #62) a legacy row saved before the nation ideology field existed loads with it absent (no recorded government)', () => { + const db = openDb(':memory:'); + // makeState has no nation.ideology field — a pre-v0.39 save. The field is + // optional and not re-stamped by load, so an absent ideology stays absent. + db.save(makeState('legacy')); + const loaded = db.load('legacy'); + expect(loaded).not.toBeNull(); + expect(loaded!.nations.uk.ideology).toBeUndefined(); + db.close(); + }); + + it('(v0.39.0 #62) a set nation ideology round-trips through save/load', () => { + const db = openDb(':memory:'); + const s = makeState('ideo'); + s.nations.uk.ideology = 'fascism'; + db.save(s); + const loaded = db.load('ideo'); + expect(loaded).not.toBeNull(); + expect(loaded!.nations.uk.ideology).toBe('fascism'); + 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 12ebdc6..7517f3f 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -47,6 +47,7 @@ import { syncWarRelations, validateEconomyOps, validateHybridOps, + validateIdeologyOps, validateIndustryOps, validateLeaderOps, validateMarkerOps, @@ -569,7 +570,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -591,7 +592,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { 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' }]); @@ -612,7 +613,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -631,7 +632,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1088,7 +1089,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1105,7 +1106,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1128,7 +1129,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: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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 @@ -1154,7 +1155,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: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1172,7 +1173,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: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1262,7 +1263,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: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1377,7 +1378,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1684,9 +1685,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: [] }, 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); + 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: [] }, ideology: { 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: [] }, ideology: { 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: [] }, ideology: { 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)', () => { @@ -1699,7 +1700,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1720,6 +1721,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); @@ -1741,6 +1743,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -1764,6 +1767,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1797,6 +1801,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1821,6 +1826,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1848,6 +1854,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1874,6 +1881,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); @@ -1900,6 +1908,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1924,6 +1933,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1946,6 +1956,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1980,6 +1991,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); @@ -2004,13 +2016,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: [] }, leaders: { changes: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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: [] }, leaders: { changes: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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 @@ -2037,6 +2049,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept @@ -2084,6 +2097,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, @@ -2109,7 +2123,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: [] }, leaders: { changes: [] }, 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: [] }, ideology: { 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 @@ -2125,14 +2139,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: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2202,6 +2216,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ @@ -2297,6 +2312,7 @@ function mkTurnResult(partial: Partial): TurnResult { industry: partial.industry ?? { projects: [] }, nuclear: partial.nuclear ?? { arm: [], strikes: [] }, leaders: partial.leaders ?? { changes: [] }, + ideology: partial.ideology ?? { changes: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3022,6 +3038,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); @@ -3410,6 +3427,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3431,6 +3449,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3452,6 +3471,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); @@ -4820,3 +4840,197 @@ describe('named leaders (v0.38.0 #61)', () => { }); }); }); + +describe('ideology / form of government (v0.39.0 #62)', () => { + // `uk` starts with an ideology ('monarchy'); `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 ideoBase = (): GameState => { + const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'monarchy' }; + 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('validateIdeologyOps', () => { + it('accepts a {changes} object and a bare array of valid changes', () => { + const fromObj = validateIdeologyOps({ + changes: [ + { target: 'uk', to: 'fascism' }, + { target: 'de', to: 'democracy' }, + ], + }); + expect(fromObj).toEqual([ + { target: 'uk', to: 'fascism' }, + { target: 'de', to: 'democracy' }, + ]); + expect(validateIdeologyOps([{ target: 'uk', to: 'x' }])).toEqual([{ target: 'uk', to: 'x' }]); + }); + + it('drops a change with a missing / empty / whitespace `to`', () => { + expect( + validateIdeologyOps({ changes: [{ target: 'uk', to: '' }, { target: 'uk', to: ' ' }] }), + ).toEqual([]); + }); + + it('drops a change with a missing / empty / whitespace target', () => { + expect( + validateIdeologyOps({ changes: [{ target: '', to: 'fascism' }, { target: ' ', to: 'fascism' }] }), + ).toEqual([]); + }); + + it('returns [] for undefined / non-object / non-array-changes input', () => { + expect(validateIdeologyOps(undefined)).toEqual([]); + expect(validateIdeologyOps('nope')).toEqual([]); + expect(validateIdeologyOps({ changes: 'nope' })).toEqual([]); + }); + + it('keeps only the valid changes of a mixed batch', () => { + const out = validateIdeologyOps({ + changes: [ + { target: 'uk', to: 'Good' }, + null, + { target: '', to: 'Bad' }, + { target: 'de', to: 'Also Good' }, + ], + }); + expect(out).toEqual([ + { target: 'uk', to: 'Good' }, + { target: 'de', to: 'Also Good' }, + ]); + }); + + it('trims both fields and caps each at 120 chars', () => { + const long = 'x'.repeat(200); + const out = validateIdeologyOps({ changes: [{ target: ' uk ', to: ` ${long} ` }] }); + expect(out).toEqual([{ target: 'uk', to: 'x'.repeat(120) }]); + }); + }); + + describe('isMeaningful — ideology', () => { + it('a turn with only an ideology change is meaningful', () => { + expect(isMeaningful(mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }))).toBe(true); + }); + it('a turn with empty ideology and nothing else is not meaningful', () => { + expect(isMeaningful(mkTurnResult({ ideology: { changes: [] } }))).toBe(false); + }); + }); + + describe('applyTurnResult — ideology', () => { + it('a valid change sets the field, dips stability −5, and adds a political event', () => { + const next = applyTurnResult( + ideoBase(), + mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }), + ); + expect(next.nations.uk.ideology).toBe('fascism'); + expect(next.nations.uk.stats.stability).toBe(45); // 50 - 5 + expect(next.regionStats.g1.stability).toBe(45); // mirrored to the region + expect(next.events).toHaveLength(1); + expect(next.events[0]!.type).toBe('political'); + expect(next.events[0]!.title).toBe('UK\'s government shifted to fascism'); + // the other nation is untouched + expect(next.nations.de.ideology).toBeUndefined(); + expect(next.nations.de.stats.stability).toBe(50); + }); + + it('re-setting the SAME ideology still dips (idempotent for the field, not the stat)', () => { + const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' }; + const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) }); + const next = applyTurnResult( + state, + mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }), + ); + expect(next.nations.uk.ideology).toBe('fascism'); // the same label + expect(next.nations.uk.stats.stability).toBe(45); // still a −5 dip + expect(next.events).toHaveLength(1); + expect(next.events[0]!.type).toBe('political'); + }); + + it('a 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']), ideology: 'monarchy' }; + const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) }); + const next = applyTurnResult( + state, + mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'dictatorship' }] } }), + ); + expect(next.regionStats.g1.stability).toBe(45); + expect(next.regionStats.g2.stability).toBe(45); + expect(next.nations.uk.stats.stability).toBe(45); // re-derived from the dipped regions + }); + + it('resolves the target by (case-insensitive) name as well as id', () => { + const next = applyTurnResult( + ideoBase(), + mkTurnResult({ ideology: { changes: [{ target: 'UK', to: 'fascism' }] } }), + ); + expect(next.nations.uk.ideology).toBe('fascism'); + expect(next.nations.uk.stats.stability).toBe(45); + }); + + it('a 0-region nation keeps its directly-dipped stat (no re-derive to zero)', () => { + const iso: Nation = { ...mkNation('iso', NS(50, 50, 50, 50), []) }; + const state = mkTurnState({ iso }, {}); + const next = applyTurnResult( + state, + mkTurnResult({ ideology: { changes: [{ target: 'iso', to: 'fascism' }] } }), + ); + expect(next.nations.iso.ideology).toBe('fascism'); + expect(next.nations.iso.stats.stability).toBe(45); // direct dip, not re-derived + }); + + it('clamps the dip at 0 (a stability of 3 becomes 0, not −2)', () => { + const low: Nation = { ...mkNation('low', NS(50, 50, 3, 50), ['g1']), ideology: 'monarchy' }; + const state = mkTurnState({ low }, { g1: RS(50, 50, 3, 50) }); + const next = applyTurnResult( + state, + mkTurnResult({ ideology: { changes: [{ target: 'low', to: 'fascism' }] } }), + ); + expect(next.nations.low.stats.stability).toBe(0); + expect(next.regionStats.g1.stability).toBe(0); + }); + + it('an unknown target is dropped without throwing (no change, no event, no crash)', () => { + const next = applyTurnResult( + ideoBase(), + mkTurnResult({ ideology: { changes: [{ target: 'ghost', to: 'fascism' }] } }), + ); + expect(next.nations.uk.ideology).toBe('monarchy'); // untouched + expect(next.nations.de.ideology).toBeUndefined(); + 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' }; + 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({ ideology: { changes: [{ target: 'Same', to: 'fascism' }] } }), + ); + // 'Same' matches two nations -> ambiguous -> dropped; neither ideology is set. + expect(next.nations.a.ideology).toBeUndefined(); + expect(next.nations.b.ideology).toBeUndefined(); + expect(next.events).toHaveLength(0); + }); + }); + + describe('buildUserPrompt — ideology', () => { + it('stamps a nation\'s ideology in the prompt nations when it has one', () => { + const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' }; + const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) }); + const parsed = JSON.parse(buildUserPrompt(state, [])) as { + state: { nations: Record }; + }; + expect(parsed.state.nations.uk.ideology).toBe('fascism'); + }); + + it('omits a nation\'s ideology from the prompt when it has none', () => { + const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']) }; + const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) }); + const parsed = JSON.parse(buildUserPrompt(state, [])) as { + state: { nations: Record }; + }; + expect(parsed.state.nations.uk.ideology).toBeUndefined(); + expect(JSON.stringify(parsed.state.nations.uk)).not.toContain('ideology'); + }); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index 4cbc013..560706a 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -31,6 +31,7 @@ import { deriveNationStats, fnv1a32, HYBRID_OP_TYPES, + IDEOLOGY_STABILITY_EFFECT, isDateLike, isMarkerType, isNuclearCapable, @@ -46,6 +47,7 @@ import { type EconomyTradeOp, type HybridOp, type HybridOpType, + type IdeologyChange, type IndustryOps, type LeaderCause, type LeaderChange, @@ -247,6 +249,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' "industry": {"projects":[{"regionKey":"","type":""}]},', ' "nuclear": {"arm":[""],"strikes":[{"from":"","target":""}]},', ' "leaders": {"changes":[{"target":"","name":"","cause":""}]},', + ' "ideology": {"changes":[{"target":"","to":""}]},', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -271,6 +274,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- 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).', + '- Ideology ("ideology") is each nation\'s form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation\'s ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message\'s "nations" field lists each nation\'s current "ideology" (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.', @@ -637,6 +641,7 @@ export function validateTurnResult( industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; @@ -770,6 +775,14 @@ export function validateTurnResult( // Applied by applyTurnResult after nuclear, before markers. result.leaders = { changes: validateLeaderOps(o.leaders) }; + // (v0.39, #62) Ideology (form of government) changes: each names a target + // nation (id or name) and a new government type. Refs stay raw here + // (resolved against the real state at apply time, like wars / hybrid / + // nuclear / leader ops); an unresolvable target or an empty label is + // dropped at apply time, never thrown. Applied by applyTurnResult after + // leaders, before markers. + result.ideology = { changes: validateIdeologyOps(o.ideology) }; + // (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). @@ -1139,6 +1152,35 @@ export function validateLeaderOps(raw: unknown): LeaderChange[] { return out; } +/** + * (v0.39, #62) Validate the raw `ideology` object of a turn into a safe + * `IdeologyChange[]`. 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) and + * a non-empty, non-whitespace `to` (the new form of government, capped at + * 120 chars). Refs stay raw here (resolved against the real state at apply + * time, like the wars / hybrid / nuclear / leader ops); an unresolvable + * target is dropped at apply time, never thrown. + */ +export function validateIdeologyOps(raw: unknown): IdeologyChange[] { + const out: IdeologyChange[] = []; + 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 to = str(s.to, 120); + if (!target || !to) continue; + out.push({ target, to }); + } + 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}, @@ -1502,6 +1544,66 @@ function applyLeaderChange( ); } +// --- Ideology / form of government (v0.39, #62) ------------------------------ + +/** + * (v0.39, #62) Apply a single ideology 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). On a valid change the target's `ideology` is set to the new + * government type (re-setting the same value is idempotent for the field but + * still narrated) and a DETERMINISTIC stability dip + * (`IDEOLOGY_STABILITY_EFFECT`, −5) 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/leader + * effects, so a 0-region nation keeps its directly-mutated value). Finally a + * narration event is appended: a `political` event for the government shift. + * The change never moves territory (sole ownership is untouched). + */ +function applyIdeologyChange( + next: GameState, + op: IdeologyChange, + 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]; + + n.ideology = op.to; + + const delta = IDEOLOGY_STABILITY_EFFECT; + 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); + } + } + + next.events.push( + makeEvent( + { + nation: n.id, + type: 'political', + title: `${n.name}'s government shifted to ${op.to}`, + text: `${n.name} has changed its form of government to ${op.to}.`, + outcome: 'success', + }, + turn, + next.date, + ), + ); +} + // --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------ /** @@ -2076,6 +2178,9 @@ export function isMeaningful(result: TurnResult): boolean { // (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.39, #62) an ideology (form of government) change this turn is a real + // world change (a nation's government type and stability moved). + result.ideology.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 @@ -2510,6 +2615,18 @@ export function applyTurnResult( // appends a narration event. A leaderless assassination/death is dropped. for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn); + // --- Ideology / form of government (v0.39, #62) --- + // + // Placed AFTER the leaders block (both are per-nation "who is in charge / + // how the state is run" levers) and BEFORE the markers block (markers + // recompute their owner from the FINAL post-claim ownership; ideology only + // touches stats, so ordering w.r.t. markers is immaterial, but keeping the + // levers grouped is clearer). Each change sets the target's ideology, + // applies a deterministic stability dip (−5), and appends a political + // narration event. An unresolvable (unknown / ambiguous) target is + // dropped, never thrown. + for (const op of result.ideology.changes) applyIdeologyChange(next, op, geoOf, actingTurn); + // --- Map markers (v0.27, #44) --- // // Markers are anchored to a region key and are processed against the FINAL @@ -3122,6 +3239,7 @@ export async function runTurn(opts: { industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, + ideology: { changes: [] }, markers: { add: [], remove: [] }, }; } diff --git a/server/protocol.test.ts b/server/protocol.test.ts index cfa86a1..1508c58 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -4,7 +4,9 @@ import { CONQUEST_MILITARY_MARGIN, deriveNationResources, deriveNationStats, + IDEOLOGY_STABILITY_EFFECT, migrateRegionStats, + nationForPrompt, seedRegionStats, weightedMedian, } from '../shared/protocol'; @@ -337,3 +339,30 @@ describe('canConquer', () => { expect(canConquer(49, 50, 0)).toBe(false); // one below → repel }); }); + +// --- IDEOLOGY_STABILITY_EFFECT (v0.39, #62) ---------------------------------- + +describe('IDEOLOGY_STABILITY_EFFECT (v0.39.0 #62)', () => { + it('a regime shift is a deterministic −5 stability dip', () => { + expect(IDEOLOGY_STABILITY_EFFECT).toBe(-5); + }); +}); + +// --- nationForPrompt — ideology (v0.39, #62) --------------------------------- + +describe('nationForPrompt — ideology (v0.39.0 #62)', () => { + it('stamps the ideology when a nation has one', () => { + const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']); + n.ideology = 'fascism'; + expect(nationForPrompt(n).ideology).toBe('fascism'); + // and it survives JSON serialisation (the user prompt path) + expect(JSON.stringify(nationForPrompt(n))).toContain('"ideology":"fascism"'); + }); + + it('omits the ideology from the JSON when a nation has none (undefined key dropped)', () => { + const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']); + expect(n.ideology).toBeUndefined(); + // JSON.stringify drops undefined-valued keys → no "ideology" in the prompt + expect(JSON.stringify(nationForPrompt(n))).not.toContain('ideology'); + }); +}); diff --git a/server/stub.ts b/server/stub.ts index ea60a1d..6ae0379 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -19,6 +19,9 @@ export interface StubPayload { // `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). + // `ideology` (v0.39.0, #62) is the per-nation form of government stamped + // by the engine; the stub ignores it — it never changes an ideology (see + // deterministicTurn). nations: Record< string, { @@ -26,6 +29,7 @@ export interface StubPayload { territory?: string[]; nuclear?: boolean; leader?: { name: string }; + ideology?: string; } >; }; @@ -160,6 +164,14 @@ function truncate(s: string, n: number): string { * strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the * nuclear / WMD path is covered by unit tests only, keeping the e2e * assertions untouched. + * + * (v0.38.0, #61) The stub likewise NEVER changes a leader (it always returns + * `leaders: { changes: [] }`) — the leader path is covered by unit tests + * only, keeping the e2e assertions untouched. + * + * (v0.39.0, #62) The stub likewise NEVER changes a nation's ideology (it + * always returns `ideology: { changes: [] }`) — the ideology path is covered + * by unit tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -233,6 +245,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { 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) + ideology: { changes: [] }, // the stub never changes ideologies (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 e4998c2..54c8285 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -510,6 +510,44 @@ export const LEADER_STABILITY_EFFECT: Record = { appointed: 0, }; +// --- Ideology / form of government (v0.39, #62) ------------------------------ +// +// Each nation may carry a free-form `ideology` label — its form of +// government ("democracy", "fascism", "military junta", …). The LLM changes it +// each turn via `TurnResult.ideology`: a change names a target nation (id or +// name) and the new government type. The engine sets the nation's `ideology` +// and applies a DETERMINISTIC stability dip (see `IDEOLOGY_STABILITY_EFFECT` +// below) — a regime shift always unsettles a nation — no hidden randomness. +// It is a third, independent axis next to `leader` (who is in charge) and the +// v0.32.0 takeover (which renames the nation): the id, name, territory, +// relations, and the leader all stay valid, only the form of government and +// stability move. + +/** A single ideology change: the target nation and its new form of government. */ +export interface IdeologyChange { + /** The target nation, by id or name (resolved by the shared resolver). */ + target: string; + /** The new form of government (a free-form label, e.g. "democracy"). */ + to: string; +} + +/** The ideology-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */ +export interface IdeologyOps { + changes: IdeologyChange[]; +} + +/** + * The deterministic stability effect of an ideology change (v0.39, #62). A + * regime shift (a coup, a revolution, a reform, a constitutional change) + * unsettles the nation: a small −5 dip applied to the target's `stability` + * (clamped 0–100), mirrored to its `regionStats`, and its derived stats + * re-derived afterwards. Applied the same way a leader-cause effect is — + * re-setting the SAME ideology still dips (the shift just happened, even if + * the label does not change) but is idempotent for the `ideology` field + * itself. + */ +export const IDEOLOGY_STABILITY_EFFECT: number = -5; + // --- Nuclear / WMD effect math (v0.37, #60) ---------------------------------- // // A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no @@ -694,6 +732,23 @@ export interface TurnResult { * no leader replacement). Required; default `{ changes: [] }`. */ leaders: LeaderOps; + /** + * (v0.39, #62) Ideology (form of government) changes this turn. Each + * change names a target nation (id or name) and its new form of + * government — a free-form label such as "democracy", "fascism", or + * "military junta". Applied directly by `applyTurnResult` (like leaders / + * hybrid / economy): the target's `ideology` is set to the new label, a + * small, deterministic stability dip (`IDEOLOGY_STABILITY_EFFECT`, −5) is + * applied (clamped, mirrored to the nation's regions, re-derived), and a + * `political` narration event is appended. Re-setting the same label is + * idempotent for the field (still narrated, still dips). Unknown / + * ambiguous targets and empty labels are dropped, never thrown; the + * change never moves territory (sole ownership is untouched). Distinct + * from a takeover (which renames the nation) and from a leader change + * (who is in charge). At most 1–2 per turn, each narrated in `events`. + * Required; default `{ changes: [] }`. + */ + ideology: IdeologyOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small @@ -1007,6 +1062,7 @@ export function nationForPrompt(n: Nation) { territory: n.territory, relations: n.relations, leader: n.leader, + ideology: n.ideology, }; } diff --git a/shared/types.ts b/shared/types.ts index 155166c..9a05de2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -88,6 +88,18 @@ export interface Nation { * each change carries a deterministic stability effect + a narration event. */ leader?: { name: string }; + /** + * The nation's current form of government (v0.39, #62) — a free-form label + * such as `"democracy"`, `"fascism"` or `"military junta"`. Optional — a + * legacy game (or a nation the LLM has never given an ideology to) has none, + * which is the "no recorded government" baseline. The LLM changes it via + * `TurnResult.ideology` (coups / revolutions / reforms / constitutional + * changes, see `IdeologyChange` in `protocol.ts`); each change carries a + * deterministic stability dip + a political narration event. Distinct from + * the v0.32.0 takeover (which renames the nation) and from `leader` (who is + * in charge): only the form of government and stability move. + */ + ideology?: string; } /** Categories a narrative event can take. */