Compare commits

...
Author SHA1 Message Date
kamilkrzywda fff4163f2c Merge pull request 'feat(#62): ideology / government-type change (v0.39.0)' (#73) from feat/62-ideology into master
CI / build + unit + e2e (push) Successful in 2m6s
2026-10-15 22:57:36 +02:00
kamilkrzywda 7866a1987d feat(#62): ideology / government-type change (v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
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
2026-10-15 20:54:18 +00:00
kamilkrzywda 9d201135b3 Merge pull request 'feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)' (#72) from feat/61-named-leaders into master
CI / build + unit + e2e (push) Successful in 2m10s
2026-10-15 22:14:24 +02:00
kamilkrzywda 8730a56170 feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
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
2026-10-15 20:09:19 +00:00
kamilkrzywda b6dcd3f979 Merge pull request 'feat(#60): nuclear/WMD — capability flag + strikes (region → no-man's-land, nation collapse + war capitulation, MAD retaliation) (v0.37.0)' (#71) from feat/60-nuclear-wmd into master
CI / build + unit + e2e (push) Successful in 2m3s
2026-10-15 21:20:30 +02:00
9 changed files with 949 additions and 41 deletions
+32
View File
@@ -2,6 +2,38 @@
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 (`"<nation>'s government shifted to <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).
- **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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.37.0",
"version": "0.39.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+44
View File
@@ -200,6 +200,50 @@ 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('(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'));
+429 -39
View File
@@ -47,7 +47,9 @@ import {
syncWarRelations,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateMarkerOps,
validateNuclearOps,
validateNationOps,
@@ -568,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: [] }, 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' }]);
@@ -590,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: [] }, 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' }]);
@@ -611,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: [] }, 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
@@ -630,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: [] }, 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' }]);
});
@@ -1087,7 +1089,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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1104,7 +1106,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: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1127,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: [] }, 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
@@ -1153,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: [] }, 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');
@@ -1171,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: [] }, 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', () => {
@@ -1261,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: [] }, 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', () => {
@@ -1376,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: [] }, 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);
});
@@ -1683,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: [] }, 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: [] }, 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)', () => {
@@ -1698,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: [] }, 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);
});
@@ -1718,7 +1720,8 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
@@ -1739,7 +1742,8 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -1762,7 +1766,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1795,7 +1800,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1819,7 +1825,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1846,7 +1853,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1872,7 +1880,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
@@ -1898,7 +1907,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1922,7 +1932,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1944,7 +1955,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1978,7 +1990,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
@@ -2003,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: [] }, 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: [] }, 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
@@ -2035,7 +2048,8 @@ 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: [] },
ideology: { changes: [] },
markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
@@ -2082,7 +2096,8 @@ 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: [] },
ideology: { changes: [] },
markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2108,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: [] }, 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
@@ -2124,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: [] }, 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: [] }, 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');
});
@@ -2200,7 +2215,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
@@ -2295,6 +2311,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
economy: partial.economy ?? { trade: [], embargo: [] },
industry: partial.industry ?? { projects: [] },
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
leaders: partial.leaders ?? { changes: [] },
ideology: partial.ideology ?? { changes: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3019,7 +3037,8 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
@@ -3407,7 +3426,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3428,7 +3448,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3449,7 +3470,8 @@ 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: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(false);
@@ -4611,7 +4633,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 +4666,371 @@ 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);
});
});
});
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<string, { ideology?: string }> };
};
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<string, { ideology?: string }> };
};
expect(parsed.state.nations.uk.ideology).toBeUndefined();
expect(JSON.stringify(parsed.state.nations.uk)).not.toContain('ideology');
});
});
});
+257
View File
@@ -31,9 +31,11 @@ import {
deriveNationStats,
fnv1a32,
HYBRID_OP_TYPES,
IDEOLOGY_STABILITY_EFFECT,
isDateLike,
isMarkerType,
isNuclearCapable,
LEADER_STABILITY_EFFECT,
makeEvent,
markerId,
MAX_MARKERS,
@@ -45,7 +47,10 @@ import {
type EconomyTradeOp,
type HybridOp,
type HybridOpType,
type IdeologyChange,
type IndustryOps,
type LeaderCause,
type LeaderChange,
type MarkerInfo,
type MarkerSpec,
type NationOp,
@@ -243,6 +248,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
' "ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -266,6 +273,8 @@ 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":"<region key>","type":"<oil|iron|food|industry>"} (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":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "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":"<nation id or name>","to":"<new form of government>"}: 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.',
@@ -631,6 +640,8 @@ export function validateTurnResult(
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
@@ -757,6 +768,21 @@ 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.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).
@@ -1093,6 +1119,68 @@ 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<string, unknown>).changes
: undefined;
if (!Array.isArray(list)) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
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.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<string, unknown>).changes
: undefined;
if (!Array.isArray(list)) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
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},
@@ -1377,6 +1465,145 @@ 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,
),
);
}
// --- 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) ------------------------------
/**
@@ -1948,6 +2175,12 @@ 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.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
@@ -2372,6 +2605,28 @@ 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);
// --- 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
@@ -2983,6 +3238,8 @@ export async function runTurn(opts: {
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
};
}
+29
View File
@@ -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');
});
});
+23 -1
View File
@@ -16,9 +16,21 @@ 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).
// `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,
{ stats: Record<string, number>; territory?: string[]; nuclear?: boolean }
{
stats: Record<string, number>;
territory?: string[];
nuclear?: boolean;
leader?: { name: string };
ideology?: string;
}
>;
};
/** The raw pending round the engine emitted (one entry per queued item). */
@@ -152,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;
@@ -224,6 +244,8 @@ 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)
ideology: { changes: [] }, // the stub never changes ideologies (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}
+114
View File
@@ -468,6 +468,86 @@ 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<LeaderCause, number> = {
assassination: -15,
death: -3,
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
@@ -637,6 +717,38 @@ 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.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
@@ -949,6 +1061,8 @@ export function nationForPrompt(n: Nation) {
stats: n.stats,
territory: n.territory,
relations: n.relations,
leader: n.leader,
ideology: n.ideology,
};
}
+20
View File
@@ -80,6 +80,26 @@ 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 };
/**
* 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. */