Per-nation named leaders (Nation.leader) and a requiredTurnResult.leaders lever. Each change ({ target, name, cause }) sets the target nation's leader and applies a deterministic stability dip by cause, mirrored to its regions and re-derived into its stats, plus a narration event:
assassination — leader killed; −15 stability; a military event. Requires the target to already have a leader (a leaderless target is dropped, never thrown).
death — natural causes; −3 stability; a political succession event. Also requires an existing leader.
appointed — sets or creates a leader; neutral (0); a political event. Always valid.
The LLM may use a nation's id or name as target (resolved by the shared resolveNationRef); an unknown or ambiguous ref is dropped without throwing. The system prompt documents the field + the "at most 1–2 per turn, narrated" rule and contrasts it with the lighter hybrid.assassinate op (a −5 dip, no leader replacement).
Changes
shared/types.ts — optional Nation.leader?: { name }.
shared/protocol.ts — LeaderCause / LeaderChange / LeaderOps, exported LEADER_STABILITY_EFFECT ({ assassination: −15, death: −3, appointed: 0 }), required TurnResult.leaders (placed between nuclear and markers, default { changes: [] }), and leader in nationForPrompt (so the user prompt carries each nation's current leader with no extra wiring).
server/engine.ts — new validateLeaderOps (defensive, never throws) and applyLeaderChange (mirrors applyHybridOp: single-stat nation-target dip + re-derive); the applyTurnResultleaders block runs after nuclear, before markers; isMeaningful counts non-empty leaders.changes; validateTurnResult + the runTurn "unclear" fallback default it; buildSystemPrompt documents the field + a constraint bullet.
server/stub.ts — deterministicTurn always returns leaders: { changes: [] } and the StubPayload nations type gains an optional leader, so the e2e (CI, LLM_STUB=1) is unaffected — the path is unit-tested only.
Tests — 15 new engine.test.ts tests (validateLeaderOps, isMeaningful, and the applyTurnResult apply cases incl. the leaderless-gate, multi-region re-derive, and unknown/ambiguous-never-throw) + 2 db.test.ts round-trips (legacy row with no leader loads absent; a set leader round-trips).
Additive: no db.ts change (leader is an optional field; a pre-v0.38 row loads with it absent). Sole ownership and all existing invariants are untouched. Version bumped 0.37.0 → 0.38.0.
Local verification: typecheck ✓, lint ✓, test 488 ✓, build ✓.
Fixes #61
## Named leaders (v0.38.0)
Per-nation named leaders (`Nation.leader`) and a **required** `TurnResult.leaders` lever. Each change (`{ target, name, cause }`) sets the target nation's leader and applies a **deterministic** stability dip by cause, mirrored to its regions and re-derived into its stats, plus a narration event:
- **`assassination`** — leader killed; **−15** stability; a **`military`** event. Requires the target to already have a leader (a leaderless target is dropped, never thrown).
- **`death`** — natural causes; **−3** stability; a **`political`** succession event. Also requires an existing leader.
- **`appointed`** — sets or creates a leader; neutral (0); a **`political`** event. Always valid.
The LLM may use a nation's **id or name** as `target` (resolved by the shared `resolveNationRef`); an unknown or ambiguous ref is dropped without throwing. The system prompt documents the field + the "at most 1–2 per turn, narrated" rule and contrasts it with the lighter `hybrid.assassinate` op (a −5 dip, no leader replacement).
### Changes
- **`shared/types.ts`** — optional `Nation.leader?: { name }`.
- **`shared/protocol.ts`** — `LeaderCause` / `LeaderChange` / `LeaderOps`, exported `LEADER_STABILITY_EFFECT` ({ assassination: −15, death: −3, appointed: 0 }), required `TurnResult.leaders` (placed between `nuclear` and `markers`, default `{ changes: [] }`), and `leader` in `nationForPrompt` (so the user prompt carries each nation's current leader with no extra wiring).
- **`server/engine.ts`** — new `validateLeaderOps` (defensive, never throws) and `applyLeaderChange` (mirrors `applyHybridOp`: single-stat nation-target dip + re-derive); the `applyTurnResult` **leaders** block runs **after `nuclear`, before `markers`**; `isMeaningful` counts non-empty `leaders.changes`; `validateTurnResult` + the `runTurn` "unclear" fallback default it; `buildSystemPrompt` documents the field + a constraint bullet.
- **`server/stub.ts`** — `deterministicTurn` always returns `leaders: { changes: [] }` and the `StubPayload` nations type gains an optional `leader`, so the **e2e (CI, `LLM_STUB=1`) is unaffected** — the path is unit-tested only.
- **Tests** — 15 new `engine.test.ts` tests (`validateLeaderOps`, `isMeaningful`, and the `applyTurnResult` apply cases incl. the leaderless-gate, multi-region re-derive, and unknown/ambiguous-never-throw) + 2 `db.test.ts` round-trips (legacy row with no leader loads absent; a set leader round-trips).
Additive: no `db.ts` change (`leader` is an optional field; a pre-v0.38 row loads with it absent). Sole ownership and all existing invariants are untouched. Version bumped `0.37.0 → 0.38.0`.
Local verification: `typecheck` ✓, `lint` ✓, `test` 488 ✓, `build` ✓.
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
kamilkrzywda
scheduled this pull request to auto merge when all checks succeed 2026-10-15 22:14:23 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #61
Named leaders (v0.38.0)
Per-nation named leaders (
Nation.leader) and a requiredTurnResult.leaderslever. Each change ({ target, name, cause }) sets the target nation's leader and applies a deterministic stability dip by cause, mirrored to its regions and re-derived into its stats, plus a narration event:assassination— leader killed; −15 stability; amilitaryevent. Requires the target to already have a leader (a leaderless target is dropped, never thrown).death— natural causes; −3 stability; apoliticalsuccession event. Also requires an existing leader.appointed— sets or creates a leader; neutral (0); apoliticalevent. Always valid.The LLM may use a nation's id or name as
target(resolved by the sharedresolveNationRef); an unknown or ambiguous ref is dropped without throwing. The system prompt documents the field + the "at most 1–2 per turn, narrated" rule and contrasts it with the lighterhybrid.assassinateop (a −5 dip, no leader replacement).Changes
shared/types.ts— optionalNation.leader?: { name }.shared/protocol.ts—LeaderCause/LeaderChange/LeaderOps, exportedLEADER_STABILITY_EFFECT({ assassination: −15, death: −3, appointed: 0 }), requiredTurnResult.leaders(placed betweennuclearandmarkers, default{ changes: [] }), andleaderinnationForPrompt(so the user prompt carries each nation's current leader with no extra wiring).server/engine.ts— newvalidateLeaderOps(defensive, never throws) andapplyLeaderChange(mirrorsapplyHybridOp: single-stat nation-target dip + re-derive); theapplyTurnResultleaders block runs afternuclear, beforemarkers;isMeaningfulcounts non-emptyleaders.changes;validateTurnResult+ therunTurn"unclear" fallback default it;buildSystemPromptdocuments the field + a constraint bullet.server/stub.ts—deterministicTurnalways returnsleaders: { changes: [] }and theStubPayloadnations type gains an optionalleader, so the e2e (CI,LLM_STUB=1) is unaffected — the path is unit-tested only.engine.test.tstests (validateLeaderOps,isMeaningful, and theapplyTurnResultapply cases incl. the leaderless-gate, multi-region re-derive, and unknown/ambiguous-never-throw) + 2db.test.tsround-trips (legacy row with no leader loads absent; a set leader round-trips).Additive: no
db.tschange (leaderis an optional field; a pre-v0.38 row loads with it absent). Sole ownership and all existing invariants are untouched. Version bumped0.37.0 → 0.38.0.Local verification:
typecheck✓,lint✓,test488 ✓,build✓.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