Compare commits

...
Author SHA1 Message Date
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
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. 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 ## 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). - **#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. - **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", "name": "history",
"version": "0.37.0", "version": "0.39.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "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(); 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', () => { it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:'); const db = openDb(':memory:');
db.save(makeState('a')); db.save(makeState('a'));
+429 -39
View File
@@ -47,7 +47,9 @@ import {
syncWarRelations, syncWarRelations,
validateEconomyOps, validateEconomyOps,
validateHybridOps, validateHybridOps,
validateIdeologyOps,
validateIndustryOps, validateIndustryOps,
validateLeaderOps,
validateMarkerOps, validateMarkerOps,
validateNuclearOps, validateNuclearOps,
validateNationOps, validateNationOps,
@@ -568,7 +570,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); 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: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -611,7 +613,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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([ expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -630,7 +632,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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' }]); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
}); });
@@ -1087,7 +1089,7 @@ describe('applyTurnResult', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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); applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
@@ -1104,7 +1106,7 @@ describe('applyTurnResult', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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); const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]); 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. // 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: [] }], territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [], 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'])); 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 // 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. // released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [], 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 const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb'); expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1171,7 +1173,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [], regionChanges: [],
relations: [], relations: [],
unowned: [], 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', () => { 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: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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', () => { 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)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect( 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); ).toBe(true);
}); });
@@ -1683,9 +1685,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => { describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => { 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: [{ 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: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(false); 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)', () => { 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: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], 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); ).toBe(true);
}); });
@@ -1718,7 +1720,8 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, 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: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -1739,7 +1742,8 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -1762,7 +1766,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1795,7 +1800,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1819,7 +1825,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1846,7 +1853,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1872,7 +1880,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}); });
expect(validated!.takeovers).toEqual([]); expect(validated!.takeovers).toEqual([]);
@@ -1898,7 +1907,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1922,7 +1932,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1944,7 +1955,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
@@ -1978,7 +1990,8 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
}; };
const next = applyTurnResult(state, result, undefined, centroidOf); 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 add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, { const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], 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); }, undefined, centroidOf);
expect(first.markers).toHaveLength(1); expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again // a second turn proposes the exact same marker again
const second = applyTurnResult(first, { const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], 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); }, undefined, centroidOf);
expect(second.markers).toHaveLength(1); expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy 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: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { markers: {
add: [ add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
@@ -2082,7 +2096,8 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { markers: {
add: [ add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2108,7 +2123,7 @@ describe('markers (point placement, v0.27 #44)', () => {
]; ];
const next = applyTurnResult(state, { const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], 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); }, undefined, centroidOf);
expect(next.markers).toHaveLength(1); expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op 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 // 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, { const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], 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); }, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull(); expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de // 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, { const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], 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); }, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de'); expect(reclaimed.markers[0].ownerNationId).toBe('de');
}); });
@@ -2200,7 +2215,8 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, 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: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
}); });
const { state } = await runTurn({ const { state } = await runTurn({
@@ -2295,6 +2311,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
economy: partial.economy ?? { trade: [], embargo: [] }, economy: partial.economy ?? { trade: [], embargo: [] },
industry: partial.industry ?? { projects: [] }, industry: partial.industry ?? { projects: [] },
nuclear: partial.nuclear ?? { arm: [], strikes: [] }, nuclear: partial.nuclear ?? { arm: [], strikes: [] },
leaders: partial.leaders ?? { changes: [] },
ideology: partial.ideology ?? { changes: [] },
markers: partial.markers ?? { add: [], remove: [] }, markers: partial.markers ?? { add: [], remove: [] },
}; };
} }
@@ -3019,7 +3037,8 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}); });
expect(withTakeovers).not.toBeNull(); expect(withTakeovers).not.toBeNull();
@@ -3407,7 +3426,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [{ sides: ['a', 'b'] }], end: [] }, wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -3428,7 +3448,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] }, wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
@@ -3449,7 +3470,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [], end: [] }, wars: { declare: [], end: [] },
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(false); ).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); 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', () => { 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'); 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, deriveNationStats,
fnv1a32, fnv1a32,
HYBRID_OP_TYPES, HYBRID_OP_TYPES,
IDEOLOGY_STABILITY_EFFECT,
isDateLike, isDateLike,
isMarkerType, isMarkerType,
isNuclearCapable, isNuclearCapable,
LEADER_STABILITY_EFFECT,
makeEvent, makeEvent,
markerId, markerId,
MAX_MARKERS, MAX_MARKERS,
@@ -45,7 +47,10 @@ import {
type EconomyTradeOp, type EconomyTradeOp,
type HybridOp, type HybridOp,
type HybridOpType, type HybridOpType,
type IdeologyChange,
type IndustryOps, type IndustryOps,
type LeaderCause,
type LeaderChange,
type MarkerInfo, type MarkerInfo,
type MarkerSpec, type MarkerSpec,
type NationOp, 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>}]},', ' "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>"}]},', ' "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>"}]},', ' "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>"]}', ' "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.', '- 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".', '- 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".', '- 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.', '- 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 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.', '- Keep every stat value between 0 and 100.',
@@ -631,6 +640,8 @@ export function validateTurnResult(
economy: { trade: [], embargo: [] }, economy: { trade: [], embargo: [] },
industry: { projects: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
@@ -757,6 +768,21 @@ export function validateTurnResult(
// (after industry, before markers). // (after industry, before markers).
result.nuclear = validateNuclearOps(o.nuclear); 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 // (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory // removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner). // 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; 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 * (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}, * 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'); 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) ------------------------------ // --- 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. // this turn is a real world change.
result.nuclear.arm.length > 0 || result.nuclear.arm.length > 0 ||
result.nuclear.strikes.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. // (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 || result.markers.add.length > 0 ||
result.markers.remove.length > 0 result.markers.remove.length > 0
@@ -2372,6 +2605,28 @@ export function applyTurnResult(
// rocket-marker half of nuclear capability. // rocket-marker half of nuclear capability.
applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn); 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) --- // --- Map markers (v0.27, #44) ---
// //
// Markers are anchored to a region key and are processed against the FINAL // 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: [] }, economy: { trade: [], embargo: [] },
industry: { projects: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
} }
+29
View File
@@ -4,7 +4,9 @@ import {
CONQUEST_MILITARY_MARGIN, CONQUEST_MILITARY_MARGIN,
deriveNationResources, deriveNationResources,
deriveNationStats, deriveNationStats,
IDEOLOGY_STABILITY_EFFECT,
migrateRegionStats, migrateRegionStats,
nationForPrompt,
seedRegionStats, seedRegionStats,
weightedMedian, weightedMedian,
} from '../shared/protocol'; } from '../shared/protocol';
@@ -337,3 +339,30 @@ describe('canConquer', () => {
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel 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 // `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 // the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
// it — it never arms or strikes (see deterministicTurn). // 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< nations: Record<
string, 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). */ /** 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 * strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
* nuclear / WMD path is covered by unit tests only, keeping the e2e * nuclear / WMD path is covered by unit tests only, keeping the e2e
* assertions untouched. * 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 { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; 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) economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
industry: { projects: [] }, // the stub never declares industry projects (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) 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) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
}; };
} }
+114
View File
@@ -468,6 +468,86 @@ export interface NuclearOps {
strikes: NuclearStrike[]; 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) ---------------------------------- // --- Nuclear / WMD effect math (v0.37, #60) ----------------------------------
// //
// A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no // 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: [] }`. * never queued. Required; default `{ arm: [], strikes: [] }`.
*/ */
nuclear: NuclearOps; 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 * (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small * (the engine positions each at its region's centroid + a small
@@ -949,6 +1061,8 @@ export function nationForPrompt(n: Nation) {
stats: n.stats, stats: n.stats,
territory: n.territory, territory: n.territory,
relations: n.relations, relations: n.relations,
leader: n.leader,
ideology: n.ideology,
}; };
} }
+20
View File
@@ -80,6 +80,26 @@ export interface Nation {
* `isNuclearCapable` in `protocol.ts`). * `isNuclearCapable` in `protocol.ts`).
*/ */
nuclear?: boolean; 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. */ /** Categories a narrative event can take. */