Files
History/server/abilities/nuclear.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

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: () => ({}),
};