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
This commit is contained in:
2026-10-15 20:09:19 +00:00
parent b6dcd3f979
commit 8730a56170
8 changed files with 469 additions and 41 deletions
+139
View File
@@ -34,6 +34,7 @@ import {
isDateLike,
isMarkerType,
isNuclearCapable,
LEADER_STABILITY_EFFECT,
makeEvent,
markerId,
MAX_MARKERS,
@@ -46,6 +47,8 @@ import {
type HybridOp,
type HybridOpType,
type IndustryOps,
type LeaderCause,
type LeaderChange,
type MarkerInfo,
type MarkerSpec,
type NationOp,
@@ -243,6 +246,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -266,6 +270,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
'- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".',
'- Leaders ("leaders") name each nation\'s current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message\'s "nations" field lists each nation\'s current "leader" (omitted when a nation has none).',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -631,6 +636,7 @@ export function validateTurnResult(
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
markers: { add: [], remove: [] },
};
@@ -757,6 +763,13 @@ export function validateTurnResult(
// (after industry, before markers).
result.nuclear = validateNuclearOps(o.nuclear);
// (v0.38, #61) Leader changes: assassinations / deaths / appointments of
// named leaders. Refs stay raw here (resolved against the real state at
// apply time, like wars / hybrid / nuclear ops); an unresolvable target or a
// leaderless assassination/death is dropped at apply time, never thrown.
// Applied by applyTurnResult after nuclear, before markers.
result.leaders = { changes: validateLeaderOps(o.leaders) };
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
@@ -1093,6 +1106,39 @@ export function validateNuclearOps(raw: unknown): NuclearOps {
return out;
}
/**
* (v0.38, #61) Validate the raw `leaders` object of a turn into a safe
* `LeaderChange[]`. Defensive like the rest of the parser — never throws.
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change
* needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty
* `name` (the new leader's name, capped at 120 chars), and a `cause` that is
* one of the three known literals (assassination / death / appointed). Refs
* stay raw here (resolved against the real state at apply time, like the wars
* / hybrid / nuclear ops); a leaderless assassination/death is dropped at
* apply time, never thrown.
*/
export function validateLeaderOps(raw: unknown): LeaderChange[] {
const out: LeaderChange[] = [];
const list = Array.isArray(raw)
? raw
: raw && typeof raw === 'object'
? (raw as Record<string, unknown>).changes
: undefined;
if (!Array.isArray(list)) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
const target = str(s.target, 120);
const name = str(s.name, 120);
if (!target || !name) continue;
const cause = typeof s.cause === 'string' ? s.cause : '';
if (cause === 'assassination' || cause === 'death' || cause === 'appointed') {
out.push({ target, name, cause: cause as LeaderCause });
}
}
return out;
}
/**
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
@@ -1377,6 +1423,85 @@ function applyHybridOp(
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
}
// --- Named leaders (v0.38, #61) -----------------------------------------------
/**
* (v0.38, #61) Apply a single leader change to `next` (in place). The target
* nation is resolved by the shared resolver (id first, then a case-insensitive
* unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE
* then applies: an `assassination` or `death` requires the target to already
* have a leader (a leaderless target is dropped); an `appointed` change is
* always valid (it creates the first leader). On a valid change the target's
* `leader` is set to the new name and a DETERMINISTIC stability dip
* (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
* (clamped 0–100) and mirrored to every region the nation owns
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
* same 0-region / no-entry guards as the hybrid/economy/industry effects, so a
* 0-region nation keeps its directly-mutated value). Finally a narration event
* is appended: a dramatic `military` event for an assassination, a `political`
* succession event for a death or an appointment. The replacement never moves
* territory (sole ownership is untouched).
*/
function applyLeaderChange(
next: GameState,
op: LeaderChange,
geoOf?: (key: string) => { area: number; population: number } | undefined,
actingTurn?: number,
): void {
const turn = actingTurn ?? next.turn;
const id = resolveNationRef(next, op.target);
if (id === null) return; // unknown / ambiguous target: drop, never throw
const n = next.nations[id];
// GATE: assassination / death require an existing leader; an appointment is
// always valid (it creates the first leader).
if (op.cause !== 'appointed' && !n.leader) return;
const prevName = n.leader?.name;
n.leader = { name: op.name };
const delta = LEADER_STABILITY_EFFECT[op.cause];
if (delta !== 0) {
n.stats.stability = clampStat(n.stats.stability + delta);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs.stability = clampStat(rs.stability + delta);
}
// Re-derive the nation's aggregate stats (the main recompute ran earlier
// this turn, so without this the dip would be washed out next turn).
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
const title =
op.cause === 'assassination'
? `${prevName ?? 'The leader'} of ${n.name} is assassinated`
: op.cause === 'death'
? `${prevName ?? 'The leader'} of ${n.name} dies`
: `${n.name} appoints ${op.name}`;
const text =
op.cause === 'assassination'
? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.`
: op.cause === 'death'
? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.`
: `${n.name} has appointed ${op.name} as its new leader.`;
next.events.push(
makeEvent(
{
nation: n.id,
type: op.cause === 'assassination' ? 'military' : 'political',
title,
text,
outcome: 'success',
},
turn,
next.date,
),
);
}
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
/**
@@ -1948,6 +2073,9 @@ export function isMeaningful(result: TurnResult): boolean {
// this turn is a real world change.
result.nuclear.arm.length > 0 ||
result.nuclear.strikes.length > 0 ||
// (v0.38, #61) a leader assassinated / died / appointed this turn is a real
// world change (a nation's leader and stability moved).
result.leaders.changes.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
@@ -2372,6 +2500,16 @@ export function applyTurnResult(
// rocket-marker half of nuclear capability.
applyNuclearOps(next, result.nuclear, state, geoOf, validKeys, regionNameOf, actingTurn);
// --- Named leaders (v0.38, #61) ---
//
// Placed AFTER the nuclear block (both are the "heavier" levers) and BEFORE
// the markers block (markers recompute their owner from the FINAL post-claim
// ownership; leaders only touch stats, so ordering w.r.t. markers is
// immaterial, but keeping the levers grouped is clearer). Each change sets
// the target's leader, applies a deterministic stability dip by cause, and
// appends a narration event. A leaderless assassination/death is dropped.
for (const op of result.leaders.changes) applyLeaderChange(next, op, geoOf, actingTurn);
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -2983,6 +3121,7 @@ export async function runTurn(opts: {
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
markers: { add: [], remove: [] },
};
}