feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s

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.
This commit is contained in:
2026-10-16 09:15:55 +00:00
parent df52261835
commit c90e9ae753
14 changed files with 984 additions and 116 deletions
+23 -36
View File
@@ -57,8 +57,9 @@ import {
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
str,
} from './abilities/shared';
import { ABILITIES } from './abilities';
import { ABILITIES, type AbilityCtx, type UserPromptCtx } from './abilities';
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
// importing these shared primitives from the engine unchanged.
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
@@ -86,6 +87,13 @@ export {
validateTakeovers,
validateWarOps,
applyIndustryEffects,
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
} from './abilities';
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
@@ -458,7 +466,7 @@ export function buildUserPrompt(
// (v0.40.0, #67) Build the UserPromptCtx once and merge each ability's
// payload() at its exact position (the key order is load-bearing for the
// golden test).
const upCtx = {
const upCtx: UserPromptCtx = {
state,
markers,
wars,
@@ -468,14 +476,13 @@ export function buildUserPrompt(
nationResources,
industryProjects,
};
const upCtxAny = upCtx as unknown as import('./abilities').UserPromptCtx;
Object.assign(payload, ABILITIES[0].payload(upCtxAny)); // takeovers → takeoverRisks
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
Object.assign(payload, ABILITIES[1].payload(upCtxAny)); // wars → wars
Object.assign(payload, ABILITIES[2].payload(upCtxAny)); // hybrid → relations
Object.assign(payload, ABILITIES[3].payload(upCtxAny)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtxAny)); // industry → resources, industry
Object.assign(payload, ABILITIES[1].payload(upCtx)); // wars → wars
Object.assign(payload, ABILITIES[2].payload(upCtx)); // hybrid → relations
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
return JSON.stringify(payload, null, 2);
}
@@ -534,13 +541,6 @@ export function parseTurnResult(
return validateTurnResult(obj, regionKeyResolver);
}
function str(s: unknown, max = 0): string | null {
if (typeof s !== 'string') return null;
const t = s.trim();
if (!t) return null;
return max ? t.slice(0, max) : t;
}
/**
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
@@ -621,6 +621,7 @@ export function validateTurnResult(
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
markers: { add: [], remove: [] },
};
@@ -711,7 +712,7 @@ export function validateTurnResult(
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
// The 8 per-feature LLM abilities (v0.40.0, #67): each one's validate call
// The 9 per-feature LLM abilities (v0.40.0, #67): each one's validate call
// is owned by its Ability module in the ABILITIES registry. The field's raw
// value is passed through a.validate() which returns the validated field shape.
for (const a of ABILITIES) {
@@ -824,19 +825,6 @@ export function validateRegionChanges(
return out;
}
/**
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
* {@link Takeover} list — the non-military regime-change lever (a coup or
* revolution that renames a nation to its new regime name). Defensive like
* the rest of the parser — never throws. Each entry needs a non-empty
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
* to a concrete nation id happens at apply time (`resolveNationRef`), not
* here. Entries with either field missing or whitespace-only are dropped.
*/
// (v0.40.0, #67) The 8 per-feature ability validators, appliers, and helpers
// have been moved to server/abilities/ — re-exported below for the tests.
export function isMeaningful(result: TurnResult): boolean {
return (
result.events.length > 0 ||
@@ -845,7 +833,7 @@ export function isMeaningful(result: TurnResult): boolean {
result.regionChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0 ||
// (v0.40.0, #67) any of the 8 per-feature abilities having a non-empty
// (v0.40.0, #67) any of the 9 per-feature abilities having a non-empty
// payload is a real world change (each Ability's isMeaningful predicate
// is the per-ability half of the old 18-sub-field check).
ABILITIES.some((a) => a.isMeaningful(result[a.field])) ||
@@ -1112,12 +1100,12 @@ export function applyTurnResult(
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
// (v0.40.0, #67) The 8 per-feature LLM abilities, applied in ABILITIES
// (v0.40.0, #67) The 9 per-feature LLM abilities, applied in ABILITIES
// order (takeovers → wars → hybrid → economy → industry → nuclear →
// leaders → ideology). Each Ability's apply method handles its own
// field's validated data against the post-territory/post-recompute
// leaders → ideology → disasters). Each Ability's apply method handles its
// own field's validated data against the post-territory/post-recompute
// `next` state. The AbilityCtx carries the shared resolvers.
const abilityCtx = {
const abilityCtx: AbilityCtx = {
previous: state,
actingTurn,
validRegionKeys: validKeys,
@@ -1126,9 +1114,8 @@ export function applyTurnResult(
resourceOf,
centroidOf,
};
const abilityCtxAny = abilityCtx as unknown as import('./abilities').AbilityCtx;
for (const a of ABILITIES) {
a.apply(next, result[a.field], abilityCtxAny);
a.apply(next, result[a.field], abilityCtx);
}
// --- Map markers (v0.27, #44) ---