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.
This commit is contained in:
2026-10-16 07:23:11 +00:00
parent 5cfbce861b
commit 2d17c743f6
11 changed files with 1831 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
// The IDEOLOGY ability (v0.39, #62) as a modular LLM-ability (v0.40.0, #67).
// The model may change a nation's form of government (its `ideology` field) —
// a free-form label such as "democracy", "fascism", or "military junta" — via
// a coup, revolution, reform, or constitutional change. On a valid change the
// target's `ideology` is set, a DETERMINISTIC stability dip
// (`IDEOLOGY_STABILITY_EFFECT`, −5) 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 change 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, IDEOLOGY_STABILITY_EFFECT } from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import type { IdeologyChange } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (v0.39, #62) Validate the raw `ideology` object of a turn into a safe
* `IdeologyChange[]`. 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) and
* a non-empty, non-whitespace `to` (the new form of government, capped at
* 120 chars). Refs stay raw here (resolved against the real state at apply
* time, like the wars / hybrid / nuclear / leader ops); an unresolvable
* target is dropped at apply time, never thrown.
*/
export function validateIdeologyOps(raw: unknown): IdeologyChange[] {
const out: IdeologyChange[] = [];
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 to = str(s.to, 120);
if (!target || !to) continue;
out.push({ target, to });
}
return out;
}
/**
* (v0.39, #62) Apply a single ideology 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). On a valid change the target's `ideology` is set to the new
* government type (re-setting the same value is idempotent for the field but
* still narrated) and a DETERMINISTIC stability dip
* (`IDEOLOGY_STABILITY_EFFECT`, −5) 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/leader
* effects, so a 0-region nation keeps its directly-mutated value). Finally a
* narration event is appended: a `political` event for the government shift.
* The change never moves territory (sole ownership is untouched).
*/
function applyIdeologyChange(
next: GameState,
op: IdeologyChange,
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];
n.ideology = op.to;
const delta = IDEOLOGY_STABILITY_EFFECT;
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);
}
}
next.events.push(
makeEvent(
{
nation: n.id,
type: 'political',
title: `${n.name}'s government shifted to ${op.to}`,
text: `${n.name} has changed its form of government to ${op.to}.`,
outcome: 'success',
},
turn,
next.date,
),
);
}
export const ideology: Ability<'ideology'> = {
field: 'ideology',
default: () => ({ changes: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ changes }` object (mirrors the historical `result.ideology = { changes: ... }`).
validate: (raw) => ({ changes: validateIdeologyOps(raw) }),
apply: (state, data, ctx) => {
for (const ch of data.changes) applyIdeologyChange(state, ch, ctx.geoOf, ctx.actingTurn);
},
schemaLine: () =>
' "ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},',
constraint: () =>
'- Ideology ("ideology") is each nation\'s form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation\'s ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message\'s "nations" field lists each nation\'s current "ideology" (omitted when a nation has none).',
isMeaningful: (data) => data.changes.length > 0,
stub: () => ({ changes: [] }),
payload: () => ({}),
};