// 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; 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; 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, ): 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, 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(); 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":[""],"strikes":[{"from":"","target":""}]},', 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: () => ({}), };