feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0) #72

Merged
kamilkrzywda merged 1 commits from feat/61-named-leaders into master 2026-10-15 22:14:25 +02:00
Owner

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 ✓.

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` ✓.
kamilkrzywda added 1 commit 2026-10-15 22:09:56 +02:00
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
kamilkrzywda merged commit 9d201135b3 into master 2026-10-15 22:14:25 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OpenCode/History#72