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
+67
View File
@@ -96,6 +96,15 @@ history/
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
│ ├── presets.ts # load preset JSON files from disk
│ ├── db.ts # SQLite open/save/load (+ legacy regionStats migration on load, v0.28 #46)
│ ├── abilities/ # the modular LLM-ability system (v0.40.0 #67) — one file per world lever
│ │ ├── index.ts # the ABILITIES registry (list order = apply order) + the re-export surface
│ │ ├── types.ts # the Ability contract + the AbilityField union
│ │ ├── shared.ts # shared helpers (stat dips / re-derive / ref resolution, …)
│ │ ├── apply-helpers.ts # shared apply-path helpers
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63)
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 9-ability contract)
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
│ └── *.test.ts # unit tests (vitest)
└── src/
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
@@ -442,6 +451,64 @@ history/
(`industry: { projects: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
only (`server/engine.test.ts`, `server/protocol.test.ts`, `server/regions.test.ts`,
`server/db.test.ts`, `scripts/build-region-resources.test.ts`).
- **Modular LLM abilities — how to add a new one (v0.40.0, #67).**
Since v0.40.0 the per-feature LLM levers are a modular registry in `server/abilities/`: one
file per ability plus `types.ts` (the `Ability<K>` interface, the closed `AbilityField` union,
and the `AbilityCtx` / `UserPromptCtx` shapes), `shared.ts` (the dependency-free `str` /
`resolveNationRef` / `resolveRegionKey` / fresh-id primitives the abilities share), and
`index.ts` (the ordered `ABILITIES` array — its position is the canonical apply / prompt /
payload order). Each ability is a plain object implementing `Ability<K>`: `field` (its
`TurnResult` field name), `default()` (a FRESH no-op value), `validate(raw)` (defensive parse
into the field's exact shape — never throws), `apply(state, data, ctx)` (mutate the world in
place — never throws), `schemaLine()` (its system-prompt JSON line), `constraint()` (its HARD
CONSTRAINTS bullet), `isMeaningful(data)` (does a non-default value make the turn meaningful?),
`stateFields?` (the `GameState` fields it owns, for the `db.ts` legacy-row migration —
`undefined` for turn-transient abilities), `stub()` (the deterministic stub's value — every
ability returns `default()`, so e2e is never affected), and `payload(ctx)` (the user-payload
keys it contributes — `{}` for turn-transient ones). The engine consumes the registry
generically and is never edited to add an ability: `buildSystemPrompt` emits each
`schemaLine()` / `constraint()` in `ABILITIES` order (between the `nationOps` block/line and the
`markers` line / "- Prefer 1-4 events" bullet), `buildUserPrompt` merges each `payload()` at its
historical position, `validateTurnResult` fills each field via `a.validate()`, `isMeaningful`
ORs the per-ability predicates, `applyTurnResult` runs `a.apply` in `ABILITIES` order (after the
core effects, before markers), the stub fills each field via `a.stub()`, and the "unclear"
fallback uses `a.default()`. To add a new ability: (1) add the field's type to `TurnResult` in
`shared/protocol.ts` (a required field with a default) and the field name to the `AbilityField`
union in `types.ts`; (2) create `server/abilities/<name>.ts` exporting an `Ability<'<name'>`
const; (3) import it in `index.ts` and APPEND it to `ABILITIES` (append last — never reorder
existing entries; their order is pinned by the goldens and the stub's byte-identical output);
(4) re-export any new public names from `index.ts` and from `engine.ts`; (5) if it owns a durable
`GameState` field, add it to `shared/types.ts` + the `db.ts` legacy-row migration (additive,
never wiped) and declare it in `stateFields`; if it contributes to the user payload, return its
keys from `payload()` and extend `UserPromptCtx`; (6) write `server/abilities/<name>.test.ts`
(the stub never exercises a new ability, so its apply path is unit-tested only) and — if it
changes the system prompt — regenerate the goldens
(`REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts`); `user.txt` must stay
byte-identical unless the new ability contributes user-payload keys.
- **Disasters & pandemics — natural / man-made catastrophes with a deterministic dip (v0.41.0, #63).**
`TurnResult.disasters = DisastersOps = { events: DisasterEvent[] }` (required, default
`{ events: [] }`); `DisasterEvent = { type, target, severity, detail? }` with `type ∈
DISASTER_TYPES = earthquake | flood | volcanic | wildfire | famine | plague | epidemic` and
`severity ∈ DISASTER_SEVERITIES = minor | moderate | severe`. Like the hybrid layer this is
**turn-transient**: it owns NO `GameState` field (no durable list, no `db.ts` change) — a
plague or epidemic that persists is simply re-declared each turn it continues. The DETERMINISTIC
`DISASTER_EFFECTS` table (`server/abilities/disasters.ts`) maps each `(type, severity)` to a
small set of signed stat deltas (a severe plague is the hardest hit; none of them touch
`population`, and none move borders — sole ownership is untouched). The `target` resolves like a
hybrid op — a nation ref via the shared `resolveNationRef` (id first, then a case-insensitive
unique name), else a region key that hits that region's SOLE owner (a key with 0 or 2+ owners, or
one absent from the valid-keys manifest, is dropped); there is no `from`/actor field — a
catastrophe is not an attack — so (unlike hybrid) a self-target drop does NOT apply.
`applyDisasterEvent` (run in the `ABILITIES` loop after territory / wars / economy, before
markers) dips the target nation's `stats` (clamped 0–100), mirrors the dip to its regions (the
single named region for a region target, else every region it owns — entries with no
`regionStats` are skipped), and re-derives the nation's aggregate stats (the same 0-region /
no-entry guards, so a 0-region nation keeps its directly-mutated value and the dip persists).
`payload()` is `{}` (turn-transient), so the user-prompt golden (`user.txt`) is unchanged; the
system prompt gains the field's schema line + constraint bullet (the system goldens were
re-baselined in v0.41.0). `isMeaningful` is `events.length > 0`. The stub never proposes a
disaster (`{ events: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
only (`server/abilities/disasters.test.ts`).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was