Files
History/server/abilities/disasters.ts
kamilkrzywda c90e9ae753
CI / build + unit + e2e (pull_request) Successful in 1m55s
feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.

- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
  DisastersOps, and `TurnResult.disasters` (between `ideology` and
  `markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
  schemaLine / constraint / isMeaningful / inert stub / empty payload; no
  stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
  dip table (population never touched). Target resolution mirrors hybrid
  (a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
  `from` field, so there is no self-target drop. Dips clamp to 0-100,
  mirror to the target's regions (regionStats), and re-derive the target
  nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
  regenerated (one schema line + one constraint bullet each); the
  user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
  updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
  the Layout tree + a "how to add a new ability" bullet + a disasters
  bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
  `as unknown` casts into the top-level ./abilities import, and de-dup
  `str` (now imported from ./abilities/shared).

Version bumped 0.40.0 -> 0.41.0.
2026-10-16 09:15:55 +00:00

260 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string>(DISASTER_TYPES);
const VALID_SEVERITIES = new Set<string>(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<DisasterType, Record<DisasterSeverity, Partial<Record<StatKey, number>>>> = {
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<string, unknown>).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<string, unknown>;
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<string>,
): { 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<Record<StatKey, number>>): 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<string>,
): 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":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short 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: () => ({}),
};