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.
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
// The NUCLEAR / WMD ability (v0.37, #60) as a modular LLM-ability (v0.40.0, #67).
|
|
// The model may name nations to ARM with the nuclear flag (`nuclear.arm`) and
|
|
// fire nuclear strikes (`nuclear.strikes`, from a capable nation at a nation or
|
|
// a region). The deterministic effects (devastating the target, MAD retaliation,
|
|
// ending the target's wars, forcing relations hostile, releasing struck regions
|
|
// to no man's land) are all applied here. The apply bodies are moved VERBATIM
|
|
// from the pre-refactor engine.ts; `NUCLEAR_COLLAPSE` / `NUCLEAR_RETALIATION` /
|
|
// `isNuclearCapable` / `makeEvent` / `deriveNationStats` come from shared/protocol.
|
|
|
|
import {
|
|
clampStat,
|
|
deriveNationStats,
|
|
isNuclearCapable,
|
|
makeEvent,
|
|
NUCLEAR_COLLAPSE,
|
|
NUCLEAR_RETALIATION,
|
|
type NuclearOps,
|
|
type NuclearStatKey,
|
|
} from '../../shared/protocol';
|
|
import type { GameState, Nation } from '../../shared/types';
|
|
import { activeWarPairs } from './apply-helpers';
|
|
import { resolveNationRef, resolveRegionKey, str } from './shared';
|
|
import type { Ability } from './types';
|
|
|
|
const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability'];
|
|
|
|
/**
|
|
* (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe
|
|
* {@link NuclearOps}. Defensive like the rest of the parser — never throws.
|
|
* `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is
|
|
* resolved (id or name) at APPLY time, so an unknown ref is dropped there, not
|
|
* here. `strikes`: each needs a non-empty `from` (the striking nation ref) and
|
|
* a non-empty `target` (a nation ref OR a region key), each capped at 120
|
|
* chars; a self-target (`from` and `target` the same ref) is dropped here. A
|
|
* missing / malformed top-level object yields `{ arm: [], strikes: [] }`.
|
|
*/
|
|
export function validateNuclearOps(raw: unknown): NuclearOps {
|
|
const out: NuclearOps = { arm: [], strikes: [] };
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
const o = raw as Record<string, unknown>;
|
|
if (Array.isArray(o.arm)) {
|
|
for (const e of o.arm) {
|
|
const s = typeof e === 'string' ? e.trim() : '';
|
|
if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120));
|
|
}
|
|
}
|
|
if (Array.isArray(o.strikes)) {
|
|
for (const e of o.strikes) {
|
|
if (!e || typeof e !== 'object') continue;
|
|
const s = e as Record<string, unknown>;
|
|
const from = str(s.from, 120);
|
|
const target = str(s.target, 120);
|
|
if (!from || !target) continue;
|
|
if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop
|
|
out.strikes.push({ from, target });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Devastate a nation's stats by a delta profile (collapse / retaliation): each
|
|
* of the three nuclear stat keys is shifted by the given delta (clamped) on
|
|
* BOTH the nation and every region it owns. Never throws.
|
|
*/
|
|
function collapseNationStats(
|
|
next: GameState,
|
|
nationId: string,
|
|
collapse: Record<NuclearStatKey, number>,
|
|
): void {
|
|
const n = next.nations[nationId];
|
|
if (!n) return;
|
|
for (const k of NUCLEAR_STATS) {
|
|
const d = collapse[k];
|
|
n.stats[k] = clampStat(n.stats[k] + d);
|
|
for (const key of n.territory) {
|
|
const rs = next.regionStats[key];
|
|
if (rs) rs[k] = clampStat(rs[k] + d);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release a region key to no man's land, reusing the turn's release semantics:
|
|
* the key leaves its SOLE owner's territory, is marked in `GameState.unowned`,
|
|
* and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an
|
|
* already-unowned key) is left untouched. Never throws.
|
|
*/
|
|
function releaseRegionToNoMansLand(next: GameState, key: string): void {
|
|
if (next.unowned.includes(key)) return; // already no man's land
|
|
const owners = Object.values(next.nations).filter((n) => n.territory.includes(key));
|
|
if (owners.length !== 1) return; // 0 or 2+ owners: not releasable
|
|
next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter(
|
|
(k) => k !== key,
|
|
);
|
|
next.unowned.push(key);
|
|
delete next.regionStats[key];
|
|
}
|
|
|
|
/**
|
|
* End every ACTIVE war whose sides include both `a` and `b` (a nuclear
|
|
* capitulation), then downgrade the `war` relation of every pair that is no
|
|
* longer covered by an active war back to `hostile` (the war → relation sync).
|
|
* Capturing the active pairs immediately BEFORE the end keeps this correct for
|
|
* a war declared and ended in the SAME turn (where the start-of-turn `prev`
|
|
* that `syncWarRelations` uses would not contain it). Never throws.
|
|
*/
|
|
function endWarsBetween(next: GameState, a: string, b: string): void {
|
|
const before = activeWarPairs(next.wars);
|
|
let ended = false;
|
|
for (const w of next.wars) {
|
|
if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) {
|
|
w.status = 'capitulation';
|
|
ended = true;
|
|
}
|
|
}
|
|
if (!ended) return;
|
|
const after = activeWarPairs(next.wars);
|
|
for (const key of before) {
|
|
if (after.has(key)) continue; // still covered by another active war
|
|
const [x, y] = key.split('\u0000');
|
|
const nx = next.nations[x];
|
|
const ny = next.nations[y];
|
|
if (!nx || !ny) continue;
|
|
if (nx.relations[y] === 'war' || ny.relations[x] === 'war') {
|
|
nx.relations[y] = 'hostile';
|
|
ny.relations[x] = 'hostile';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force the relation between two nations to `hostile` in BOTH directions —
|
|
* the aftermath of a MAD exchange. A `war` relation is never downgraded (the
|
|
* war entity owns it while active — e.g. a region strike that devastates one
|
|
* warring side without ending the war). Never throws.
|
|
*/
|
|
function forceHostile(next: GameState, a: string, b: string): void {
|
|
const na = next.nations[a];
|
|
const nb = next.nations[b];
|
|
if (!na || !nb) return;
|
|
if (na.relations[b] !== 'war') na.relations[b] = 'hostile';
|
|
if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile';
|
|
}
|
|
|
|
/**
|
|
* Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in
|
|
* place and never throwing. See the section comment above for the full
|
|
* contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the
|
|
* "owns a rocket" half of nuclear capability (a rocket marker placed this very
|
|
* turn makes its owner capable only from the next turn, while a nation that
|
|
* already owns one this turn is capable now).
|
|
*/
|
|
export function applyNuclearOps(
|
|
next: GameState,
|
|
ops: NuclearOps,
|
|
state: GameState,
|
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
|
validKeys?: ReadonlySet<string>,
|
|
regionNameOf?: (key: string) => string | undefined,
|
|
actingTurn?: number,
|
|
): void {
|
|
if (ops.arm.length === 0 && ops.strikes.length === 0) return;
|
|
const turn = actingTurn ?? next.turn;
|
|
const markers = state.markers ?? [];
|
|
const capable = (id: string): boolean => {
|
|
const n = next.nations[id];
|
|
return n ? isNuclearCapable(n, markers) : false;
|
|
};
|
|
|
|
// (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped.
|
|
for (const ref of ops.arm) {
|
|
const id = resolveNationRef(next, ref);
|
|
if (id === null) continue;
|
|
const n = next.nations[id];
|
|
if (!n) continue;
|
|
n.nuclear = true;
|
|
}
|
|
|
|
const affected = new Set<string>();
|
|
const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key;
|
|
|
|
// (2) STRIKES: each validated independently against the post-arm state.
|
|
for (const strike of ops.strikes) {
|
|
const fromId = resolveNationRef(next, strike.from);
|
|
if (fromId === null) continue; // unknown / ambiguous striker: drop
|
|
const from = next.nations[fromId];
|
|
if (!from) continue;
|
|
if (!capable(fromId)) continue; // a non-capable nation cannot strike
|
|
if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop
|
|
|
|
// Resolve the target: a NATION (a ref resolving to a distinct nation) or a
|
|
// REGION (a region key / name with a sole owner that is not the striker).
|
|
let target:
|
|
| { kind: 'nation'; nation: Nation }
|
|
| { kind: 'region'; key: string; owner: Nation }
|
|
| null = null;
|
|
const tid = resolveNationRef(next, strike.target);
|
|
if (tid !== null) {
|
|
const tn = next.nations[tid];
|
|
if (tn) target = { kind: 'nation', nation: tn };
|
|
} else {
|
|
const rk = validKeys
|
|
? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined))
|
|
: (strike.target.trim() || null);
|
|
if (rk !== null) {
|
|
const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk));
|
|
if (owners.length === 1 && owners[0]!.id !== fromId) {
|
|
target = { kind: 'region', key: rk, owner: owners[0]! };
|
|
}
|
|
// a 0/2+-owner region, or a region the striker owns (self), is dropped
|
|
}
|
|
}
|
|
if (target === null) continue; // unresolvable target: drop
|
|
|
|
if (target.kind === 'nation') {
|
|
// Devastate the target nation.
|
|
collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE);
|
|
affected.add(target.nation.id);
|
|
// A strike on a nation at war with the striker ends that war in
|
|
// capitulation (the relation then falls back to hostile).
|
|
endWarsBetween(next, fromId, target.nation.id);
|
|
// MAD: a nuclear-capable target strikes back at half strength and the
|
|
// two nations end the exchange at least hostile.
|
|
if (capable(target.nation.id)) {
|
|
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
|
|
affected.add(fromId);
|
|
forceHostile(next, fromId, target.nation.id);
|
|
next.events.push(
|
|
makeEvent(
|
|
{
|
|
nation: fromId,
|
|
type: 'military',
|
|
title: 'MAD retaliation',
|
|
text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`,
|
|
outcome: 'failure',
|
|
},
|
|
turn,
|
|
next.date,
|
|
),
|
|
);
|
|
}
|
|
next.events.push(
|
|
makeEvent(
|
|
{
|
|
nation: fromId,
|
|
type: 'military',
|
|
title: `Nuclear strike on ${target.nation.name}`,
|
|
text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`,
|
|
outcome: 'success',
|
|
},
|
|
turn,
|
|
next.date,
|
|
),
|
|
);
|
|
} else {
|
|
// Devastate the region: release it to no man's land, then devastate its
|
|
// former owner.
|
|
const owner = target.owner;
|
|
releaseRegionToNoMansLand(next, target.key);
|
|
collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE);
|
|
affected.add(owner.id);
|
|
// MAD: a nuclear-capable owner strikes back at half strength and the
|
|
// two nations end the exchange at least hostile.
|
|
if (capable(owner.id)) {
|
|
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
|
|
affected.add(fromId);
|
|
forceHostile(next, fromId, owner.id);
|
|
next.events.push(
|
|
makeEvent(
|
|
{
|
|
nation: fromId,
|
|
type: 'military',
|
|
title: 'MAD retaliation',
|
|
text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay(
|
|
target.key,
|
|
)}; mutual assured destruction devastates ${from.name} in turn.`,
|
|
outcome: 'failure',
|
|
},
|
|
turn,
|
|
next.date,
|
|
),
|
|
);
|
|
}
|
|
next.events.push(
|
|
makeEvent(
|
|
{
|
|
nation: fromId,
|
|
type: 'military',
|
|
title: `Nuclear strike on ${regionDisplay(target.key)}`,
|
|
text: `${from.name} obliterates ${regionDisplay(
|
|
target.key,
|
|
)} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`,
|
|
outcome: 'success',
|
|
},
|
|
turn,
|
|
next.date,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region /
|
|
// no-entry guards as the main recompute), so the collapse persists.
|
|
for (const id of affected) {
|
|
const n = next.nations[id];
|
|
if (!n) continue;
|
|
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
|
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
|
}
|
|
}
|
|
|
|
export const nuclear: Ability<'nuclear'> = {
|
|
field: 'nuclear',
|
|
default: () => ({ arm: [], strikes: [] }),
|
|
validate: (raw) => validateNuclearOps(raw),
|
|
apply: (state, data, ctx) => {
|
|
applyNuclearOps(
|
|
state,
|
|
data,
|
|
ctx.previous,
|
|
ctx.geoOf,
|
|
ctx.validRegionKeys,
|
|
ctx.regionNameOf,
|
|
ctx.actingTurn,
|
|
);
|
|
},
|
|
schemaLine: () =>
|
|
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
|
|
constraint: () =>
|
|
'- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".',
|
|
isMeaningful: (data) => data.arm.length > 0 || data.strikes.length > 0,
|
|
stub: () => ({ arm: [], strikes: [] }),
|
|
payload: () => ({}),
|
|
};
|