feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user