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.
157 lines
8.0 KiB
TypeScript
157 lines
8.0 KiB
TypeScript
// 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: () => ({}),
|
||
};
|