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