// 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: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], }; } /** 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)); }); });