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.
101 lines
5.3 KiB
TypeScript
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 } : {};
|
|
},
|
|
};
|