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
+35 -13
View File
@@ -3,13 +3,16 @@
// These tests pin the refactor's two load-bearing guarantees:
//
// 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user
// prompt the refactored engine builds are BYTE-IDENTICAL to the committed
// golden fixtures in ./goldens/ (captured in P1 from the pre-refactor
// engine). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here.
// prompt the engine builds are BYTE-IDENTICAL to the committed golden
// fixtures in ./goldens/ (captured in P1 from the pre-refactor engine, and
// re-baselined when a feature INTENTIONALLY changes the prompt — e.g. the
// system goldens were regenerated in v0.41.0 when the disasters ability
// was added). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here. The USER prompt golden is unchanged
// by the disasters feature (its payload is always empty).
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 8 abilities; each ability's default() equals the canonical
// list of the 9 abilities; each ability's default() equals the canonical
// TurnResult default for its field, is a FRESH value (never a shared
// reference), its default is not "meaningful" while a populated value is,
// and the registry composes (a hypothetical extra ability can be appended
@@ -26,13 +29,19 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
activeWarPairs,
applyDisasterEvent,
applyIndustryEffects,
atWar,
buildSystemPrompt,
buildUserPrompt,
DISASTER_EFFECTS,
DISASTER_SEVERITIES,
DISASTER_TYPES,
freshNationId,
freshWarId,
HYBRID_STAT_EFFECT,
isDisasterSeverity,
isDisasterType,
isHybridOpType,
isIndustryType,
NEW_NATION_STATS,
@@ -40,6 +49,7 @@ import {
resolveRegionKey,
shiftRelationTowardHostile,
syncWarRelations,
validateDisasterEvents,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
@@ -206,6 +216,7 @@ function fullDefaultTurn(): TurnResult {
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
markers: { add: [], remove: [] },
};
}
@@ -229,11 +240,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { changes: [{ target: 'a1', name: 'New Alpha Leader', cause: 'appointed' }] };
case 'ideology':
return { changes: [{ target: 'a1', to: 'democracy' }] };
case 'disasters':
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 8-ability order (takeovers → ideology)', () => {
it('is the canonical 9-ability order (takeovers → disasters)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
@@ -243,13 +256,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'nuclear',
'leaders',
'ideology',
'disasters',
]);
});
it('lists 8 distinct fields, each a key of TurnResult', () => {
it('lists 9 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(8);
expect(new Set(fields).size).toBe(8);
expect(fields).toHaveLength(9);
expect(new Set(fields).size).toBe(9);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
@@ -314,7 +328,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
// The registry is a plain ordered array the engine iterates uniformly. A
// structural (field-name-agnostic) loop over the abilities — schema line,
// constraint bullet — must keep working when a 9th ability is appended.
// constraint bullet — must keep working when a 10th ability is appended.
// This documents the extension point without widening the closed
// AbilityField union.
interface Loopable {
@@ -333,9 +347,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'custom',
]);
// every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(9);
expect(extended.map((a) => a.constraint())).toHaveLength(9);
expect(extended[8].schemaLine()).toBe(' "custom": [],');
expect(extended.map((a) => a.schemaLine())).toHaveLength(10);
expect(extended.map((a) => a.constraint())).toHaveLength(10);
expect(extended[9].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
@@ -366,6 +380,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
validateTakeovers,
validateWarOps,
applyIndustryEffects,
// disasters (v0.41.0, #63)
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();