Files
History/server/abilities/leaders.ts
T
kamilkrzywda 2d17c743f6 refactor(#74) P3: add the 8 ability modules + ABILITIES registry
Each per-feature LLM ability is now a self-contained module in
server/abilities/ with its own Ability<K> const (schema line, constraint
bullet, validator, applier, isMeaningful, stub default, user-payload
field). The ordered ABILITIES registry (index.ts) assembles them in the
canonical order: takeovers → wars → hybrid → economy → industry → nuclear
→ leaders → ideology.

All function bodies are moved VERBATIM from engine.ts. The engine is
untouched in this commit — P4 will rewire it to use the registry.
2026-10-16 07:23:11 +00:00

157 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The NAMED-LEADERS ability (v0.38, #61) as a modular LLM-ability (v0.40.0,
// #67). The model may change a nation's head of state: an `assassination`
// (a dramatic, destabilizing blow — a −15 stability dip, a "military" event,
// only valid when the target already has a leader), a `death` (a natural-
// causes succession — a quieter −3 dip, a "political" event, also only valid
// for a nation that has a leader), or an `appointed` change (sets or creates
// a leader and is always valid — a neutral 0 effect, a "political" event).
// On a valid change the target's `leader` is set, the DETERMINISTIC stability
// dip (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
// (clamped) 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), and a narration event is appended. The replacement never
// moves territory (sole ownership is untouched). All bodies are moved
// VERBATIM from the pre-refactor engine.ts.
import { deriveNationStats, makeEvent } from '../../shared/protocol';
import { clampStat, LEADER_STABILITY_EFFECT } from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import type { LeaderCause, LeaderChange } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (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.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,
),
);
}
export const leaders: Ability<'leaders'> = {
field: 'leaders',
default: () => ({ changes: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ changes }` object (mirrors the historical `result.leaders = { changes: ... }`).
validate: (raw) => ({ changes: validateLeaderOps(raw) }),
apply: (state, data, ctx) => {
// 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 data.changes) applyLeaderChange(state, op, ctx.geoOf, ctx.actingTurn);
},
schemaLine: () =>
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
constraint: () =>
'- 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).',
isMeaningful: (data) => data.changes.length > 0,
stub: () => ({ changes: [] }),
payload: () => ({}),
};