// 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).changes : undefined; if (!Array.isArray(list)) return out; for (const e of list) { if (!e || typeof e !== 'object') continue; const s = e as Record; 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":"","to":""}]},', 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":"","to":""}: 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: () => ({}), };