diff --git a/AGENTS.md b/AGENTS.md index 09ea4d5..e8c6d5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 +│ │ ├── .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/); 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` 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`: `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/.ts` exporting an `Ability<'` + 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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3449138..0771ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to **History** are listed here, newest first. +## v0.41.0 — 2026-10-16 09:07 +- **#63: Disasters & pandemics — a `TurnResult.disasters` lever letting the LLM declare natural/man-made disasters (earthquake / flood / volcanic / wildfire / famine / plague / epidemic) on a nation or a region, with a deterministic stat dip (stability / economy / military).** It is a **turn-transient** feature, exactly like the v0.34 `hybrid` layer: **no new `GameState` field, no `db.ts` change, no durable list** — the ops are consumed inside `applyTurnResult` and leave nothing behind. It lands as the **9th ability** of the v0.40.0 modular system, in a new `server/abilities/disasters.ts` module, **registered last** in the `ABILITIES` registry (after `ideology`, before the loop appends `markers` / `nationOps`). + - **Types (`shared/protocol.ts`).** New `DisasterType` (`'earthquake' | 'flood' | 'volcanic' | 'wildfire' | 'famine' | 'plague' | 'epidemic'`), `DisasterSeverity` (`'minor' | 'moderate' | 'severe'`), `DisasterEvent` (`{ type; target; severity; detail? }` — `target` is a nation ref **or** a region key), and `DisastersOps` (`{ events: DisasterEvent[] }`); `TurnResult.disasters` is **required**, placed between `ideology` and `markers`, default `{ events: [] }`. + - **Ability module (`server/abilities/disasters.ts`).** `field: 'disasters'`; `default()` / `stub()` → a **fresh** `{ events: [] }` each call; `validate` wraps the exported `validateDisasterEvents` (defensive, never throws — accepts the `{ events: [...] }` object or a bare array; each event needs a valid `type`, a valid `severity`, and a non-empty `target` (trimmed, ≤120) — anything else is dropped, with a per-turn cap); `apply` runs `applyDisasterEvent` per event; `schemaLine()` / `constraint()` supply the prompt text; `payload()` → `{}` (no user-payload keys — targets resolve from the existing ref table); `isMeaningful` = `events.length > 0`; **no** `stateFields` (turn-transient, no durable `GameState` field). + - **Deterministic effects (`DISASTER_EFFECTS`).** A pure `(type, severity) → stat-dip` table — **no hidden randomness**. `population` is never touched. Earthquake and famine dip **both** `stability` and `economy` (−2 / −4 / −7 for minor / moderate / severe); flood dips `economy` (−2 / −3 / −6) and `stability` (−1 / −2 / −4); volcanic dips `economy` (−2 / −4 / −7) and `stability` (−2 / −3 / −6); wildfire dips **only** `economy` (−2 / −4 / −7); plague dips `stability` (−3 / −5 / −9) and `military` (−2 / −3 / −6); epidemic dips `stability` (−2 / −4 / −7) and `military` (−1 / −2 / −4). + - **Apply semantics (`applyDisasterEvent`).** The `target` is resolved exactly like a hybrid op (`resolveHybridTarget`): a resolvable nation ref hits every region the nation owns; a region key hits its **sole** owner; a 0-owner or 2+-owner region key is **dropped** (never thrown). The one deliberate difference from hybrid: a disaster has **no `from` field**, so there is **no self-target drop** — a nation may suffer a disaster it itself declared. The dip is clamped to 0–100, **mirrored to every region the target owns** (`regionStats`), and the target's derived `stats` is **re-derived** afterwards (the same 0-region / no-entry guards as hybrid / economy / industry — a 0-region nation keeps its directly-mutated value). It never moves territory (sole ownership is untouched). The **stub never** declares a disaster (`{ events: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only. + - **Registry & prompt.** `disasters` is added last in `ABILITIES`; the system prompt gains the `disasters` schema line and its constraint bullet ("at most 1–2 per turn, each narrated in `events`"); the user prompt is unchanged (`payload()` is `{}`). The two committed system-prompt **goldens** (`system-no-tool.txt`, `system-tool.txt`) are regenerated (each gains exactly the new `disasters` schema line + constraint bullet); the **`user.txt` golden stays byte-identical** (no new user-payload keys — verified by checksum). + - **Tests.** New `server/abilities/disasters.test.ts` (**33 tests**): the type/severity predicate sets; `validateDisasterEvents` (shape / leniency / drops / cap); `applyDisasterEvent` exact dips (nation target, single-region target, region gated by `validKeys`, clamping at 0, 0-owner / 2+-owner region drops, unknown-key drop, unknown / ambiguous ref drops, case-insensitive name resolve, 0-region direct-dip persistence, missing-`regionStats`-entry skip); and the ability const contract (field, no `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload() {}`, `validate` wrapping, `apply` ctx wiring). `registry.test.ts` is updated for the **9-ability** contract (order, fields, `fullDefaultTurn()`, `meaningfulFor`, re-export pin); the goldens are regenerated (verified idempotent). +- **Secondary cleanups from the v0.40.0 refactor (folded into this release):** + - **AGENTS.md** — `server/abilities/` is added to the Layout tree (the per-ability files, tests, and goldens); Conventions gains a "Modular LLM abilities — how to add a new one (v0.40.0, #67)" bullet and a "Disasters & pandemics (v0.41.0, #63)" bullet. + - The **orphaned `validateTakeovers` JSDoc** (left over after the v0.40 refactor removed the function body) is deleted. + - Both **inline `as unknown` double-casts** are folded into the single top-level `./abilities` import (`AbilityCtx` + `UserPromptCtx` types). + - **`str()` is de-duplicated** — imported from `./abilities/shared` instead of the local copy. +- **Guarantees.** No client (`src/`) changes, no new runtime deps, `shared/` keeps zero runtime deps, and no `@turf`/`@types/geojson` in any `.ts`. All unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is disasters-inert). +- **Version** bumped `0.40.0 → 0.41.0`. + ## v0.40.0 — 2026-10-16 07:14 - **#67: Modular LLM-ability system — the 8 per-feature LLM abilities are extracted from `server/engine.ts` into an ordered `ABILITIES` registry (`server/abilities/`).** A pure, **behavior-preserving** refactor: each per-ability concern (validate / schema-line / constraint / payload / meaningfulness / apply / stub-default / durable-state-field) moves into one file per ability, assembled into a single ordered registry that the engine iterates uniformly. **Nothing observable changes** — the system prompt, user prompt, `applyTurnResult` world-state mutations, and the deterministic stub are all byte-for-byte identical to v0.39.0 (pinned by new golden fixtures + registry tests), and `server/engine.ts` re-exports every name its existing callers import, so `server/index.ts` and all tests work with zero edits. - **New `server/abilities/` package.** diff --git a/package.json b/package.json index 7095d3f..23974c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.40.0", + "version": "0.41.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/abilities/disasters.test.ts b/server/abilities/disasters.test.ts new file mode 100644 index 0000000..8067f3c --- /dev/null +++ b/server/abilities/disasters.test.ts @@ -0,0 +1,445 @@ +// Unit tests for the DISASTERS & PANDEMICS ability (v0.41.0, #63) — the +// modular, turn-transient LLM-ability that applies a deterministic +// (type, severity) stat dip to a nation or a single region. This is the +// ability's own test file (the registry's cross-cutting guarantees — order, +// distinct fields, the system/user goldens — are covered by +// ./registry.test.ts). The stub never proposes a disaster, so the apply path +// is exercised here, not by the e2e suite. + +import { describe, expect, it } from 'vitest'; + +import type { AbilityCtx } from './types'; +import { + DISASTER_EFFECTS, + DISASTER_SEVERITIES, + DISASTER_TYPES, + applyDisasterEvent, + disasters, + isDisasterSeverity, + isDisasterType, + validateDisasterEvents, +} from './disasters'; + +import type { DisasterEvent, DisastersOps } from '../../shared/protocol'; +import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +/** Build a NationStats from the four canonical indices (military, economy, stability, population). */ +function NS(military: number, economy: number, stability: number, population: number): NationStats { + return { military, economy, stability, population }; +} + +/** Build a RegionStats from the four canonical indices. */ +function RS(military: number, economy: number, stability: number, population: number): RegionStats { + return { military, economy, stability, population }; +} + +/** A minimal nation: the given id/name/stats/region-key territory, neutral relations. */ +function mkNation(id: string, name: string, stats: NationStats, territory: string[]): Nation { + return { id, name, type: 'major', stats, territory, relations: {} }; +} + +/** + * A minimal GameState carrying the given nations (keyed by id) and per-region + * stats. Every other required field is at its fresh-game baseline, so a test + * can focus purely on the disaster dip. + */ +function mkState( + nations: Record, + regionStats: Record, +): GameState { + return { + id: 'g1', + presetId: 'test', + presetTitle: 'Test', + playerNationId: Object.keys(nations)[0] ?? 'uk', + turn: 1, + date: '1939-09-01', + nations, + events: [], + pending: [], + version: 1, + unowned: [], + pendingNationOps: [], + markers: [], + regionStats, + wars: [], + trades: [], + embargoes: [], + industry: [], + }; +} + +/** A minimal AbilityCtx for the ability's `apply` (no manifests loaded). */ +function mkCtx(state: GameState, validRegionKeys?: ReadonlySet): AbilityCtx { + return { + previous: state, + actingTurn: state.turn, + ...(validRegionKeys ? { validRegionKeys } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Type / severity constants + predicates +// --------------------------------------------------------------------------- + +describe('DISASTER_TYPES / DISASTER_SEVERITIES', () => { + it('exposes exactly the seven types and three severities', () => { + expect([...DISASTER_TYPES]).toEqual([ + 'earthquake', + 'flood', + 'volcanic', + 'wildfire', + 'famine', + 'plague', + 'epidemic', + ]); + expect([...DISASTER_SEVERITIES]).toEqual(['minor', 'moderate', 'severe']); + }); + + it('every (type, severity) pair dips at least one stat, never population', () => { + for (const t of DISASTER_TYPES) { + for (const s of DISASTER_SEVERITIES) { + const dips = DISASTER_EFFECTS[t][s]; + expect(dips).toBeDefined(); + const keys = Object.keys(dips); + expect(keys.length).toBeGreaterThan(0); + expect(keys).not.toContain('population'); + for (const k of keys) expect(dips[k as keyof typeof dips]).toBeLessThan(0); + } + } + }); +}); + +describe('isDisasterType / isDisasterSeverity', () => { + it('accepts each known type and rejects unknown / non-strings', () => { + for (const t of DISASTER_TYPES) expect(isDisasterType(t)).toBe(true); + expect(isDisasterType('tsunami')).toBe(false); + expect(isDisasterType('Earthquake')).toBe(false); // case-sensitive + expect(isDisasterType('')).toBe(false); + expect(isDisasterType(42)).toBe(false); + expect(isDisasterType(null)).toBe(false); + expect(isDisasterType(undefined)).toBe(false); + }); + + it('accepts each known severity and rejects unknown / non-strings', () => { + for (const s of DISASTER_SEVERITIES) expect(isDisasterSeverity(s)).toBe(true); + expect(isDisasterSeverity('extreme')).toBe(false); + expect(isDisasterSeverity('Minor')).toBe(false); // case-sensitive + expect(isDisasterSeverity('')).toBe(false); + expect(isDisasterSeverity(3)).toBe(false); + expect(isDisasterSeverity(undefined)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// validateDisasterEvents +// --------------------------------------------------------------------------- + +describe('validateDisasterEvents', () => { + const valid: DisasterEvent = { type: 'earthquake', target: 'uk', severity: 'severe' }; + + it('accepts the { events: [...] } shape', () => { + expect(validateDisasterEvents({ events: [valid] })).toEqual([valid]); + }); + + it('accepts a bare array (lenient about the wrapper)', () => { + expect(validateDisasterEvents([valid])).toEqual([valid]); + }); + + it('drops an event with an unknown type, keeping the valid one', () => { + const out = validateDisasterEvents({ + events: [ + { type: 'tsunami', target: 'uk', severity: 'minor' }, + valid, + ], + }); + expect(out).toEqual([valid]); + }); + + it('drops an event with a missing type', () => { + const out = validateDisasterEvents({ + events: [{ target: 'uk', severity: 'minor' }, valid], + }); + expect(out).toEqual([valid]); + }); + + it('drops an event with an unknown severity, keeping the valid one', () => { + const out = validateDisasterEvents({ + events: [ + { type: 'plague', target: 'uk', severity: 'extreme' }, + valid, + ], + }); + expect(out).toEqual([valid]); + }); + + it('drops an event with a missing severity', () => { + const out = validateDisasterEvents({ + events: [{ type: 'plague', target: 'uk' }, valid], + }); + expect(out).toEqual([valid]); + }); + + it('drops an event with an empty / whitespace target', () => { + const out = validateDisasterEvents({ + events: [ + { type: 'plague', target: ' ', severity: 'minor' }, + { type: 'plague', target: '', severity: 'minor' }, + valid, + ], + }); + expect(out).toEqual([valid]); + }); + + it('caps a non-empty detail at 300 chars and keeps a short one', () => { + const long = 'x'.repeat(500); + const out = validateDisasterEvents({ + events: [ + { type: 'plague', target: 'uk', severity: 'minor', detail: long }, + { type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' }, + ], + }); + expect(out).toEqual([ + { type: 'plague', target: 'uk', severity: 'minor', detail: 'x'.repeat(300) }, + { type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' }, + ]); + }); + + it('omits the detail key entirely when detail is empty / missing', () => { + const out = validateDisasterEvents({ + events: [ + { type: 'flood', target: 'uk', severity: 'minor', detail: '' }, + { type: 'flood', target: 'de', severity: 'minor' }, + ], + }); + expect(out).toEqual([ + { type: 'flood', target: 'uk', severity: 'minor' }, + { type: 'flood', target: 'de', severity: 'minor' }, + ]); + expect(out[0]).not.toHaveProperty('detail'); + }); + + it('drops non-object event entries', () => { + const out = validateDisasterEvents({ events: ['not-an-object', 7, null, valid] }); + expect(out).toEqual([valid]); + }); + + it('yields { events: [] } (here, []) for a missing / malformed top-level value', () => { + expect(validateDisasterEvents(undefined)).toEqual([]); + expect(validateDisasterEvents(null)).toEqual([]); + expect(validateDisasterEvents('nope')).toEqual([]); + expect(validateDisasterEvents(5)).toEqual([]); + expect(validateDisasterEvents({})).toEqual([]); + expect(validateDisasterEvents({ events: 'not-an-array' })).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// applyDisasterEvent — the deterministic dip +// --------------------------------------------------------------------------- + +describe('applyDisasterEvent', () => { + it('a nation target: dips the nation AND every region it owns, then re-derives (identical values pass through)', () => { + // uk owns three identical regions; all stats identical → the re-derived + // aggregate equals the dipped values exactly. + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2', 'g3']) }, + { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), g3: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' }); + // earthquake moderate = stability -4, economy -4 (military / population untouched) + expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50)); + for (const k of ['g1', 'g2', 'g3']) expect(state.regionStats[k]).toEqual(RS(50, 46, 46, 50)); + }); + + it('a region target: dips ONLY the named region (the sibling region is untouched)', () => { + // uk owns g1 and g2 (identical). Target the region key g1 → only g1 dips. + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) }, + { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'wildfire', target: 'g1', severity: 'severe' }); + // wildfire severe = economy -7 only + expect(state.regionStats.g1).toEqual(RS(50, 43, 50, 50)); + expect(state.regionStats.g2).toEqual(RS(50, 50, 50, 50)); // untouched + // re-derive (equal weights, min-biased median) → the nation takes the dipped value + expect(state.nations.uk.stats).toEqual(NS(50, 43, 50, 50)); + }); + + it('a region target resolved by a validKeys-gated key hits its sole owner', () => { + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent( + state, + { type: 'famine', target: 'g1', severity: 'moderate' }, + undefined, + new Set(['g1']), + ); + // famine moderate = economy -4, stability -4 + expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50)); + expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50)); + }); + + it('clamps every dip at 0 (never negative)', () => { + // low stability + military; a severe plague (stability -9, military -6) floors both. + const state = mkState( + { uk: mkNation('uk', 'UK', NS(3, 50, 3, 50), ['g1']) }, + { g1: RS(3, 50, 3, 50) }, + ); + applyDisasterEvent(state, { type: 'plague', target: 'uk', severity: 'severe' }); + expect(state.nations.uk.stats).toEqual(NS(0, 50, 0, 50)); + expect(state.regionStats.g1).toEqual(RS(0, 50, 0, 50)); + }); + + it('a 0-owner region is untargetable (the event is dropped, nothing dips)', () => { + // 'x1' is owned by nobody; no validKeys supplied, so the owners check drops it. + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'plague', target: 'x1', severity: 'severe' }); + expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched + expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50)); + }); + + it('a region with 2+ owners is untargetable (the event is dropped, neither dips)', () => { + // corrupt double-ownership of 'y1' + const state = mkState( + { + uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['y1']), + de: mkNation('de', 'DE', NS(60, 60, 60, 60), ['y1']), + }, + { y1: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'plague', target: 'y1', severity: 'severe' }); + expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched + expect(state.nations.de.stats).toEqual(NS(60, 60, 60, 60)); // untouched + }); + + it('an unknown region key (gated by validKeys) is dropped', () => { + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + // 'zzz' is not in the manifest → dropped before the owners check. + applyDisasterEvent(state, { type: 'plague', target: 'zzz', severity: 'severe' }, undefined, new Set(['g1'])); + expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); + }); + + it('an unknown nation reference is dropped (no throw, no dip)', () => { + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'plague', target: 'ghost', severity: 'severe' }); + expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); + expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50)); + }); + + it('an ambiguous nation reference (two share the name) is dropped', () => { + const state = mkState( + { + a: mkNation('a', 'Same', NS(50, 50, 50, 50), ['g1']), + b: mkNation('b', 'Same', NS(70, 70, 70, 70), ['d1']), + }, + { g1: RS(50, 50, 50, 50), d1: RS(70, 70, 70, 70) }, + ); + applyDisasterEvent(state, { type: 'plague', target: 'Same', severity: 'severe' }); + expect(state.nations.a.stats).toEqual(NS(50, 50, 50, 50)); // untouched + expect(state.nations.b.stats).toEqual(NS(70, 70, 70, 70)); // untouched + }); + + it('resolves a nation by (case-insensitive) name, not just id', () => { + const state = mkState( + { uk: mkNation('uk', 'United Kingdom', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'epidemic', target: 'united kingdom', severity: 'moderate' }); + // epidemic moderate = stability -4, military -2 + expect(state.nations.uk.stats).toEqual(NS(48, 50, 46, 50)); + expect(state.regionStats.g1).toEqual(RS(48, 50, 46, 50)); + }); + + it('a 0-region nation keeps its directly-dipped stats (no re-derive to zero)', () => { + const iso = mkNation('iso', 'Iso', NS(50, 50, 50, 50), []); + const state = mkState({ iso }, {}); + applyDisasterEvent(state, { type: 'epidemic', target: 'iso', severity: 'moderate' }); + // epidemic moderate = stability -4, military -2 — the direct dip persists + expect(state.nations.iso.stats).toEqual(NS(48, 50, 46, 50)); + expect(state.regionStats).toEqual({}); + }); + + it('a region with no regionStats entry is skipped (the nation still dips + re-derives from its other regions)', () => { + // uk owns g1 (has stats) and g2 (no entry). Target the nation → the dip + // mirrors to g1 only (g2 has no entry to dip); re-derive uses g1 alone. + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) }, + { g1: RS(50, 50, 50, 50) }, + ); + applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' }); + expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50)); + expect(state.regionStats).not.toHaveProperty('g2'); // never created + expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50)); + }); +}); + +// --------------------------------------------------------------------------- +// The Ability object +// --------------------------------------------------------------------------- + +describe('disasters (Ability)', () => { + it('owns the "disasters" field and declares NO durable state fields (turn-transient)', () => { + expect(disasters.field).toBe('disasters'); + expect(disasters.stateFields).toBeUndefined(); + }); + + it('default() and stub() are fresh, empty { events: [] } values', () => { + expect(disasters.default()).toEqual({ events: [] }); + expect(disasters.stub()).toEqual({ events: [] }); + // fresh on every call (never a shared mutable reference) + expect(disasters.default()).not.toBe(disasters.default()); + expect(disasters.default().events).not.toBe(disasters.default().events); + expect(disasters.stub()).not.toBe(disasters.stub()); + }); + + it('isMeaningful is true iff there is at least one event', () => { + expect(disasters.isMeaningful({ events: [] })).toBe(false); + expect(disasters.isMeaningful({ events: [{ type: 'plague', target: 'uk', severity: 'minor' }] })).toBe( + true, + ); + }); + + it('payload() is always empty (the layer is turn-transient)', () => { + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + expect(disasters.payload({ state })).toEqual({}); + }); + + it('validate() wraps the low-level validator into the { events } field shape', () => { + const valid = { type: 'flood', target: 'uk', severity: 'minor' } as DisasterEvent; + expect(disasters.validate({ events: [valid] })).toEqual({ events: [valid] }); + expect(disasters.validate(undefined)).toEqual({ events: [] }); + expect(disasters.validate([valid])).toEqual({ events: [valid] }); // bare array leniency + }); + + it('apply() wires ctx.geoOf + ctx.validRegionKeys through to the dip', () => { + const state = mkState( + { uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) }, + { g1: RS(50, 50, 50, 50) }, + ); + const ctx = mkCtx(state, new Set(['g1'])); + const data: DisastersOps = { events: [{ type: 'volcanic', target: 'g1', severity: 'moderate' }] }; + disasters.apply(state, data, ctx); + // volcanic moderate = economy -4, stability -3 + expect(state.regionStats.g1).toEqual(RS(50, 46, 47, 50)); + expect(state.nations.uk.stats).toEqual(NS(50, 46, 47, 50)); + }); +}); diff --git a/server/abilities/disasters.ts b/server/abilities/disasters.ts new file mode 100644 index 0000000..e693f44 --- /dev/null +++ b/server/abilities/disasters.ts @@ -0,0 +1,259 @@ +// 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(DISASTER_TYPES); +const VALID_SEVERITIES = new Set(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>>> = { + 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).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; + 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, +): { 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>): 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, +): 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":"","target":"","severity":"","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: () => ({}), +}; diff --git a/server/abilities/goldens/system-no-tool.txt b/server/abilities/goldens/system-no-tool.txt index 2683d7a..6efcc56 100644 --- a/server/abilities/goldens/system-no-tool.txt +++ b/server/abilities/goldens/system-no-tool.txt @@ -39,6 +39,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen "nuclear": {"arm":[""],"strikes":[{"from":"","target":""}]}, "leaders": {"changes":[{"target":"","name":"","cause":""}]}, "ideology": {"changes":[{"target":"","to":""}]}, + "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -64,6 +65,7 @@ HARD CONSTRAINTS - Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events". - Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none). - Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none). +- 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). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/goldens/system-tool.txt b/server/abilities/goldens/system-tool.txt index 0de4b7c..89608ac 100644 --- a/server/abilities/goldens/system-tool.txt +++ b/server/abilities/goldens/system-tool.txt @@ -39,6 +39,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that "nuclear": {"arm":[""],"strikes":[{"from":"","target":""}]}, "leaders": {"changes":[{"target":"","name":"","cause":""}]}, "ideology": {"changes":[{"target":"","to":""}]}, + "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -64,6 +65,7 @@ HARD CONSTRAINTS - Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events". - Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none). - Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none). +- 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). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/index.ts b/server/abilities/index.ts index 8a61987..3c103c9 100644 --- a/server/abilities/index.ts +++ b/server/abilities/index.ts @@ -1,8 +1,8 @@ // The ordered LLM-ability registry. // -// `ABILITIES` is the single source of truth for the 8 per-feature LLM abilities. +// `ABILITIES` is the single source of truth for the 9 per-feature LLM abilities. // The engine iterates it in this order: -// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology +// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters // for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability // validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt, // (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check, @@ -22,10 +22,11 @@ import { industry } from './industry'; import { nuclear } from './nuclear'; import { leaders } from './leaders'; import { ideology } from './ideology'; +import { disasters } from './disasters'; import type { Ability, AbilityField } from './types'; /** - * The 8 per-feature LLM abilities in their canonical apply order. + * The 9 per-feature LLM abilities in their canonical apply order. * The order matters: it determines the order of the schema lines in the * system prompt, the order of the validate calls, the order of the apply * steps, and the order of the payload fields in the user prompt. @@ -39,6 +40,7 @@ export const ABILITIES: readonly Ability[] = [ nuclear, leaders, ideology, + disasters, ]; // ── type re-exports ──────────────────────────────────────────────────────────── @@ -125,3 +127,15 @@ export { export { validateIdeologyOps, } from './ideology'; + +// ── disasters ──────────────────────────────────────────────────────────────── + +export { + validateDisasterEvents, + applyDisasterEvent, + isDisasterType, + isDisasterSeverity, + DISASTER_TYPES, + DISASTER_SEVERITIES, + DISASTER_EFFECTS, +} from './disasters'; diff --git a/server/abilities/registry.test.ts b/server/abilities/registry.test.ts index ec27bfe..4ed62df 100644 --- a/server/abilities/registry.test.ts +++ b/server/abilities/registry.test.ts @@ -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(); diff --git a/server/abilities/types.ts b/server/abilities/types.ts index f3f671c..1cb4181 100644 --- a/server/abilities/types.ts +++ b/server/abilities/types.ts @@ -17,11 +17,11 @@ import type { import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; /** - * The 8 per-feature LLM abilities a turn can exercise, by the `TurnResult` + * The 9 per-feature LLM abilities a turn can exercise, by the `TurnResult` * field each one owns. This is the closed universe the ordered `ABILITIES` * registry (./index.ts) is built from: a turn's world changes are exactly the * core engine effects (events / stats / territory / no-man's-land / nation-ops - * / markers) plus, in order, these eight abilities. + * / markers) plus, in order, these nine abilities. */ export type AbilityField = | 'takeovers' @@ -31,7 +31,8 @@ export type AbilityField = | 'industry' | 'nuclear' | 'leaders' - | 'ideology'; + | 'ideology' + | 'disasters'; /** * Everything a single ability's `apply` may need from the surrounding turn. @@ -64,7 +65,7 @@ export interface AbilityCtx { readonly centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined; /** * The `getNeighbours` tool executor (verify land borders). Available to an - * ability that needs it; none of the current eight use it (it drives the + * ability that needs it; none of the current nine use it (it drives the * core territory/conquest logic), but the type is here so a future one can. */ readonly getNeighbours?: (regionKeys: string[]) => string[]; @@ -151,7 +152,7 @@ export interface Ability { * The `GameState` fields this ability owns and that must be defaulted on a * legacy SQLite row (db.ts migration, only-if-undefined). `undefined` for * abilities that add no durable state (takeovers, hybrid, nuclear, leaders, - * ideology). + * ideology, disasters). */ readonly stateFields?: readonly string[]; diff --git a/server/engine.test.ts b/server/engine.test.ts index 7517f3f..04721ec 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -570,7 +570,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -592,7 +592,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -613,7 +613,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -632,7 +632,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1089,7 +1089,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1106,7 +1106,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1129,7 +1129,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -1155,7 +1155,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1173,7 +1173,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1263,7 +1263,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1378,7 +1378,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1685,9 +1685,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1700,7 +1700,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1721,8 +1721,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); @@ -1743,8 +1742,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1767,8 +1765,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1801,8 +1798,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.playerNationId).toBe('uk'); // player still leads it @@ -1826,8 +1822,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no nation was renamed @@ -1854,8 +1849,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // neither was renamed (ambiguous) @@ -1881,8 +1875,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); const next = applyTurnResult(state, validated!); @@ -1908,8 +1901,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.uk.name).toBe('The Commonwealth'); @@ -1933,8 +1925,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.version).toBe(2); // exactly one bump for the whole turn @@ -1956,8 +1947,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1991,8 +1981,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); expect(next.markers).toHaveLength(1); @@ -2016,13 +2005,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -2049,8 +2038,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped @@ -2097,8 +2085,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewB', regionKey: 'new-b' }, @@ -2123,7 +2110,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2139,14 +2126,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2216,8 +2203,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ llm: fakeLlm([result]), @@ -2313,6 +2299,7 @@ function mkTurnResult(partial: Partial): TurnResult { nuclear: partial.nuclear ?? { arm: [], strikes: [] }, leaders: partial.leaders ?? { changes: [] }, ideology: partial.ideology ?? { changes: [] }, + disasters: partial.disasters ?? { events: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3038,8 +3025,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); // the valid one survives, the empty-name one is dropped @@ -3427,8 +3413,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3449,8 +3434,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3471,8 +3455,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, - markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, }), ).toBe(false); }); diff --git a/server/engine.ts b/server/engine.ts index 88db771..88c7039 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -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) --- diff --git a/server/stub.ts b/server/stub.ts index c99ca59..c33e340 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -252,13 +252,13 @@ export function deterministicTurn(p: StubPayload): TurnResult { nationOps: [], // the stub never proposes nation ops (see doc comment) }; - // The 8 ability fields, filled by the registry in its canonical order. The + // The 9 ability fields, filled by the registry in its canonical order. The // stub is ability-inert: every ability contributes its own no-op default // (each ability's `stub()` — see the per-ability doc comments above), so the // e2e assertions are unaffected. The insertion order here (ABILITIES) is the // historical key order pinned by the stub's deterministic output // (takeovers → wars → hybrid → economy → industry → nuclear → leaders → - // ideology), which must stay byte-identical. + // ideology → disasters), which must stay byte-identical. const abilities = {} as Record; for (const a of ABILITIES) abilities[a.field] = a.stub(); diff --git a/shared/protocol.ts b/shared/protocol.ts index 54c8285..a0ffc88 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -598,6 +598,58 @@ export function isNuclearCapable(nation: Nation, markers: readonly MapMarker[]): return markers.some((m) => m.ownerNationId === nation.id && m.type === 'rocket'); } +// --- Disasters & pandemics (v0.41.0, #63) ------------------------------------ +// +// Natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, +// wildfires, famines, plagues, and epidemics — hit a nation or a single region +// each turn. Each event carries a `type` and a `severity`; the engine applies +// a DETERMINISTIC stat dip chosen from the `(type, severity)` lookup table +// `DISASTER_EFFECTS` (defined in `server/abilities/disasters.ts`) — no hidden +// randomness: the LLM chooses the type and severity, the table decides the +// magnitude. The dip is clamped to 0–100, mirrored to the target's regions +// (`regionStats`), and the target's derived `stats` re-derived afterwards +// (the same 0-region / no-entry guards as the hybrid / economy layers). It is +// **turn-transient** like the hybrid layer: there is NO `GameState` field, no +// durable list, and no `db.ts` change — an ongoing plague / epidemic is simply +// re-declared each turn it persists. Disasters never move territory (sole +// ownership is untouched) and never touch `population`. + +/** The kind of catastrophe a disaster event describes (v0.41.0, #63). */ +export type DisasterType = + | 'earthquake' + | 'flood' + | 'volcanic' + | 'wildfire' + | 'famine' + | 'plague' + | 'epidemic'; + +/** The severity a disaster event carries (v0.41.0, #63). */ +export type DisasterSeverity = 'minor' | 'moderate' | 'severe'; + +/** + * A single disaster event (v0.41.0, #63): the catastrophe `type`, its + * `target` (a nation id / name, or a region key that hits that region's sole + * owner — resolved at apply time), the `severity`, and an optional short + * `detail`. The deterministic stat dip is looked up from + * `DISASTER_EFFECTS[type][severity]`. + */ +export interface DisasterEvent { + /** The kind of catastrophe (one of {@link DisasterType}). */ + type: DisasterType; + /** The affected nation (by id or name) or a region key (hits its sole owner). */ + target: string; + /** How bad it was (one of {@link DisasterSeverity}). */ + severity: DisasterSeverity; + /** Optional short annotation (≤300 chars). */ + detail?: string; +} + +/** The disasters block of a `TurnResult`. Required; default `{ events: [] }`. */ +export interface DisastersOps { + events: DisasterEvent[]; +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -749,6 +801,24 @@ export interface TurnResult { * Required; default `{ changes: [] }`. */ ideology: IdeologyOps; + /** + * (v0.41.0, #63) Disasters & pandemics that struck this turn — earthquakes, + * floods, volcanic eruptions, wildfires, famines, plagues, and epidemics, + * each hitting a **nation** (by id or name) or a single **region** (by key, + * which hits that region's SOLE owner — a key with 0 or 2+ owners can't be + * hit). Each event names a `type` (one of the seven `DisasterType`s) and a + * `severity` (minor / moderate / severe); the engine applies a small, + * DETERMINISTIC stat dip from the `(type, severity)` table + * (`DISASTER_EFFECTS`, in `server/abilities/disasters.ts`) — clamped to + * 0–100, mirrored to the target's regions, and the target's derived `stats` + * re-derived afterwards (no hidden randomness). They NEVER move borders + * (sole ownership is untouched) and never touch `population`, and the layer + * is turn-transient (no durable state): an ongoing plague / epidemic is + * simply re-declared each turn it persists. At most 1–2 events per turn, + * each narrated in `events` (a `disaster` event). Required; default + * `{ events: [] }`. + */ + disasters: DisastersOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small