Files
History/server/abilities/takeovers.ts
kamilkrzywda 2d17c743f6 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.
2026-10-16 07:23:11 +00:00

101 lines
5.3 KiB
TypeScript

// 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<string, unknown>;
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":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
constraint: () =>
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} 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<string>();
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 } : {};
},
};