// The TAKEOVER ability (v0.32.0, #54) as a modular LLM-ability (v0.40.0, #67). // The model may overthrow a low-stability nation hosting a "gathering" marker // (a coup / revolution) — an AUTOMATIC, non-military regime change that RENAMES // the nation to its new regime (the stable id, territory, relations, stats and // playerNationId all survive). The validator and the apply body are moved // VERBATIM from the pre-refactor engine.ts; the user-payload `takeoverRisks` // hint (v0.30, #50 — the bounded soft-lever nudge listing the nations ripe for // a takeover) moves here too, since it is this ability's own prompt data. import type { GameState } from '../../shared/types'; import type { Takeover } from '../../shared/protocol'; import { resolveNationRef, str } from './shared'; import type { Ability } from './types'; /** * (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe * {@link Takeover} list — the non-military regime-change lever (a coup or * revolution that renames a nation to its new regime name). Defensive like * the rest of the parser — never throws. Each entry needs a non-empty * `nationRef` (the target nation's id or name — capped at 200 chars) and a * non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef` * to a concrete nation id happens at apply time (`resolveNationRef`), not * here. Entries with either field missing or whitespace-only are dropped. */ export function validateTakeovers(raw: unknown): Takeover[] { if (!Array.isArray(raw)) return []; const out: Takeover[] = []; for (const e of raw) { if (!e || typeof e !== 'object') continue; const o = e as Record; const nationRef = str(o.nationRef, 200); const newRegimeName = str(o.newRegimeName, 200); if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName }); } return out; } /** * (v0.32.0, #54) Apply a takeover to `next` (in place). For each takeover: * 1. Resolve the target nation via `resolveNationRef` (id first, then * case-insensitive unique name). Skip (no-op) if `null` — an * unknown or ambiguous ref is silently dropped, never thrown. * 2. Rename the resolved nation: set `n.name = newRegimeName`. Only * the display name changes — the stable id, territory, relations * (keyed by id), stats, and `playerNationId` (which references the * id, not the name) are all preserved. The player keeps leading an * overthrown nation under its new regime. */ export function applyTakeover(next: GameState, t: Takeover): void { const id = resolveNationRef(next, t.nationRef); if (id === null) return; // unknown / ambiguous ref: no-op const n = next.nations[id]; if (!n) return; // defensive: id resolved but nation missing (corrupt state) n.name = t.newRegimeName; } export const takeovers: Ability<'takeovers'> = { field: 'takeovers', default: () => [], validate: (raw) => validateTakeovers(raw), apply: (state, data) => { // Applied after the per-region recompute (the rename doesn't depend on // territory or stats, so it is order-independent with respect to those // steps) and before the markers block (a marker's owner is a nation id, // not a name, so the rename does not affect the owner recompute). for (const t of data) applyTakeover(state, t); }, schemaLine: () => ' "takeovers": [{"nationRef":"","newRegimeName":""}],', constraint: () => '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.', isMeaningful: (data) => data.length > 0, stub: () => [], payload: (ctx) => { // (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30 // AND that currently have a "gathering" marker on one of their regions are // the ones ripe for a NON-military takeover (a coup / revolution / faction // seizure) — the model can act on them this turn. One line per matching // nation, emitted only when there is at least one; the rest of the payload // is left untouched. A soft nudge only — there is no hard gate behind it. let takeoverRisks: string[] | undefined; if (ctx.markers !== undefined && ctx.markers.length > 0) { const risks = new Set(); for (const m of ctx.markers) { if (m.type !== 'gathering') continue; const ownerId = m.owner; if (!ownerId) continue; const n = ctx.state.nations[ownerId]; if (n && n.stats.stability < 30) risks.add(n.id); } if (risks.size > 0) { takeoverRisks = [...risks].map((id) => { const n = ctx.state.nations[id]; return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`; }); } } return takeoverRisks !== undefined ? { takeoverRisks } : {}; }, };