// The DISASTERS & PANDEMICS ability (v0.41.0, #63) as a modular LLM-ability // (v0.40.0, #67). Natural and man-made catastrophes — earthquakes, floods, // volcanic eruptions, wildfires, famines, plagues, and epidemics — hit a // nation or a single region each turn. Unlike most other abilities this one // is **turn-transient**: it owns NO `GameState` field (no durable list, no // `db.ts` change) — exactly like the hybrid layer. Each event carries a // `type` and a `severity`, and the engine applies a DETERMINISTIC stat dip // chosen from the `(type, severity)` lookup table below (no hidden // randomness — the LLM chooses the type and severity, the table decides the // magnitude). The dip lands on the target nation's `stats` (clamped 0–100) // AND on the target's regions (`regionStats` — the single named region when // the target was a region key, otherwise every region the target owns), and // the target's derived `stats` are re-derived afterwards (the same 0-region / // no-entry guards as hybrid / economy / industry, so a 0-region nation keeps // its directly-mutated value and the dip persists to the next turn). The // target is a nation (resolved by the shared `resolveNationRef`) or a region // key (which hits its SOLE owner); a key with 0 or 2+ owners is untargetable. // There is no acting-nation (`from`) field on a disaster — a catastrophe is // not an attack, so the hybrid's self-target drop does not apply. Malformed // events and unresolvable targets are dropped, never thrown. import { clampStat, deriveNationStats } from '../../shared/protocol'; import type { DisasterEvent, DisasterSeverity, DisasterType, } from '../../shared/protocol'; import type { GameState, Nation, NationStats, StatKey } from '../../shared/types'; import { resolveNationRef, str } from './shared'; import type { Ability } from './types'; /** The seven disaster types a turn can declare (v0.41.0, #63). */ export const DISASTER_TYPES: readonly DisasterType[] = [ 'earthquake', 'flood', 'volcanic', 'wildfire', 'famine', 'plague', 'epidemic', ]; /** The three severity levels a disaster event can carry (v0.41.0, #63). */ export const DISASTER_SEVERITIES: readonly DisasterSeverity[] = ['minor', 'moderate', 'severe']; const VALID_DISASTER_TYPES = new Set(DISASTER_TYPES); const VALID_SEVERITIES = new Set(DISASTER_SEVERITIES); /** True when `t` is one of the seven known disaster types (v0.41.0, #63). */ export function isDisasterType(t: unknown): t is DisasterType { return typeof t === 'string' && VALID_DISASTER_TYPES.has(t); } /** True when `s` is one of the three known disaster severities (v0.41.0, #63). */ export function isDisasterSeverity(s: unknown): s is DisasterSeverity { return typeof s === 'string' && VALID_SEVERITIES.has(s); } /** * (v0.41.0, #63) The DETERMINISTIC per-`(type, severity)` stat dip of a * disaster event. Each pair maps to a small set of signed (negative) stat * deltas: no hidden randomness — the LLM picks the type and severity, the * table decides the magnitude. Population is never touched (a disaster hurts * military / economy / stability, never the head-count). Every (type, * severity) combination dips at least one stat (none is purely * informational). */ export const DISASTER_EFFECTS: Record>>> = { earthquake: { minor: { stability: -2, economy: -2 }, moderate: { stability: -4, economy: -4 }, severe: { stability: -7, economy: -7 }, }, flood: { minor: { economy: -2, stability: -1 }, moderate: { economy: -3, stability: -2 }, severe: { economy: -6, stability: -4 }, }, volcanic: { minor: { economy: -2, stability: -2 }, moderate: { economy: -4, stability: -3 }, severe: { economy: -7, stability: -6 }, }, wildfire: { minor: { economy: -2 }, moderate: { economy: -4 }, severe: { economy: -7 }, }, famine: { minor: { economy: -2, stability: -2 }, moderate: { economy: -4, stability: -4 }, severe: { economy: -7, stability: -7 }, }, plague: { minor: { stability: -3, military: -2 }, moderate: { stability: -5, military: -3 }, severe: { stability: -9, military: -6 }, }, epidemic: { minor: { stability: -2, military: -1 }, moderate: { stability: -4, military: -2 }, severe: { stability: -7, military: -4 }, }, }; /** * (v0.41.0, #63) Validate the raw `disasters` field of a turn into a safe * list of {@link DisasterEvent}s. Defensive like the rest of the parser — never throws. * The raw value may be the object `{ events: [...] }` (the TurnResult shape) * or a bare array (be lenient about either). Each event needs a known `type` * (one of {@link DISASTER_TYPES}), a known `severity` (one of * {@link DISASTER_SEVERITIES}), and a non-empty `target` (a nation id/name or * a region key, capped at 200 chars); a non-empty `detail` is capped at 300 * chars. An event with an unknown / missing `type`, an unknown / missing * `severity`, or an empty `target` is dropped. Resolution of `target` to a * concrete nation (or a region's sole owner) happens at apply time * (`applyDisasterEvent`), not here. A missing / malformed top-level value * yields `{ events: [] }`. */ export function validateDisasterEvents(raw: unknown): DisasterEvent[] { let list: unknown[] | null = null; if (Array.isArray(raw)) { list = raw; } else if (raw && typeof raw === 'object') { const inner = (raw as Record).events; if (Array.isArray(inner)) list = inner; } if (!list) return []; const out: DisasterEvent[] = []; for (const e of list) { if (!e || typeof e !== 'object') continue; const o = e as Record; if (!isDisasterType(o.type)) continue; // unknown / missing type: drop if (!isDisasterSeverity(o.severity)) continue; // unknown / missing severity: drop const target = str(o.target, 200); if (!target) continue; // empty / missing target: drop const detail = str(o.detail, 300); out.push({ type: o.type, target, severity: o.severity, ...(detail ? { detail } : {}), }); } return out; } /** * (v0.41.0, #63) Resolve a disaster event's `target` (a nation id/name OR a * region key) to the concrete nation it affects. A nation ref resolves via * {@link resolveNationRef}; a region key resolves to its SOLE owner (a region * with zero or 2+ owners can't be a valid target). Returns the affected * nation plus the specific region key (when the target was a region) so the * dip can land on just that region's entry. `validKeys` (when supplied) * gates the region-key path; a ref that is neither a known nation nor a * valid region key returns `null`. Pure; never throws. * * NOTE: there is no acting-nation (`from`) field on a disaster — a * catastrophe is not an attack, so (unlike the hybrid layer) a self-target * drop does NOT apply: an earthquake can hit the nation it is declared * against. */ function resolveDisasterTarget( state: GameState, target: string, validKeys?: ReadonlySet, ): { nation: Nation; regionKey?: string } | null { const id = resolveNationRef(state, target); if (id !== null) { const n = state.nations[id]; return n ? { nation: n } : null; } const key = (target ?? '').trim(); if (!key) return null; if (validKeys && !validKeys.has(key)) return null; // unknown region key const owners = Object.values(state.nations).filter((n) => n.territory.includes(key)); if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region return { nation: owners[0], regionKey: key }; } /** Apply each signed dip in `dips` to a stats record in place (clamped). */ function applyDips(stats: NationStats, dips: Partial>): void { (Object.keys(dips) as StatKey[]).forEach((k) => { const d = dips[k]; if (typeof d === 'number') stats[k] = clampStat(stats[k] + d); }); } /** * (v0.41.0, #63) Apply ONE disaster event to the (cloned) `next` state, * mutating it in place and never throwing. Resolution order: * 1. `target` must resolve to a concrete nation (a nation ref, or a region * key's sole owner); an unresolvable target (unknown / ambiguous ref, a * region with 0 or 2+ owners, or an unknown region key) drops the event. * Then the deterministic `(type, severity)` dip from {@link DISASTER_EFFECTS} * is applied: * - the target nation's `stats` is dipped on each affected stat (clamped * 0–100); * - the dip is mirrored to the target's regions — the single named region * when the target was a region key, otherwise every region the target * owns (regions with no `regionStats` entry are skipped); * - the target nation's derived `stats` are re-derived afterwards (the * same 0-region / no-entry guards as the main recompute, so a 0-region * nation keeps its directly-mutated value and a dip persists). * A disaster never moves territory (sole ownership is untouched) and never * touches `population`. */ export function applyDisasterEvent( next: GameState, ev: DisasterEvent, geoOf?: (key: string) => { area: number; population: number } | undefined, validKeys?: ReadonlySet, ): void { const target = resolveDisasterTarget(next, ev.target, validKeys); if (target === null) return; // unresolvable target: drop const dips = DISASTER_EFFECTS[ev.type][ev.severity]; if (!dips) return; // defensive: unknown (type, severity) pair applyDips(target.nation.stats, dips); if (target.regionKey !== undefined) { const rs = next.regionStats[target.regionKey]; if (rs) applyDips(rs, dips); } else { for (const key of target.nation.territory) { const rs = next.regionStats[key]; if (!rs) continue; // a key with no entry is skipped applyDips(rs, dips); } } // Re-derive the target nation's aggregate stats so the dip persists (the // main recompute ran earlier this turn; without this the dip would be // washed out next turn). Same 0-region / no-entry guards as hybrid / // economy: a nation with no region stats entry keeps its directly-mutated // `stats`. if (target.nation.territory.length > 0 && target.nation.territory.some((k) => next.regionStats[k])) { target.nation.stats = deriveNationStats(target.nation.territory, next.regionStats, geoOf); } } export const disasters: Ability<'disasters'> = { field: 'disasters', default: () => ({ events: [] }), // The low-level validator returns a bare list; the field's shape is the // `{ events }` object (mirrors the historical TurnResult shape). validate: (raw) => ({ events: validateDisasterEvents(raw) }), apply: (state, data, ctx) => { for (const ev of data.events) { applyDisasterEvent(state, ev, ctx.geoOf, ctx.validRegionKeys); } }, schemaLine: () => ' "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]},', constraint: () => '- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region\'s SOLE owner — a region with 0 or 2+ owners can\'t be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).', isMeaningful: (data) => data.events.length > 0, stub: () => ({ events: [] }), // The layer is turn-transient — it contributes nothing to the user payload // (no durable state to show the model), so the payload is always empty. payload: () => ({}), };