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:
@@ -0,0 +1,156 @@
|
||||
// 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: () => ({}),
|
||||
};
|
||||
Reference in New Issue
Block a user