diff --git a/public/games/age-of-dwarves/guide/src/simulation/__tests__/event-system-tier2.test.ts b/public/games/age-of-dwarves/guide/src/simulation/__tests__/event-system-tier2.test.ts new file mode 100644 index 00000000..09cdeea8 --- /dev/null +++ b/public/games/age-of-dwarves/guide/src/simulation/__tests__/event-system-tier2.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest' +import { EventEvaluator } from '@magic-civ/engine-ts/eventSystem' +import type { EventCatalog, ActiveEffect } from '@magic-civ/engine-ts/eventSystem' +import { + makeGrid, VOLCANIC_CATALOG, SOLAR_CATALOG, CROSS_TRIGGERS, +} from './event-system-helpers' + +// --------------------------------------------------------------------------- +// Tier 2 — Cross-trigger tests +// --------------------------------------------------------------------------- + +describe('EventEvaluator — Tier 2: cross-triggers', () => { + describe('drought_t4_boosts_wildfire', () => { + it('creates wildfire frequency boost active effect after drought T4', () => { + const catalog: EventCatalog = { + drought: { + base_frequency: 0.05, + severity_weights: [30, 20, 15, 10, 5, 0, 0, 0, 0, 0], + tiers: { '4': { name: 'Severe Drought', radius: 5, moisture_loss: 0.2 } }, + }, + wildfire: { + base_frequency: 0.01, + severity_weights: [60, 30, 10, 0, 0, 0, 0, 0, 0, 0], + tiers: { '1': { name: 'Wildfire', radius: 3, canopy_loss: 0.3 } }, + }, + } + const evaluator = new EventEvaluator(catalog, CROSS_TRIGGERS, 42) + const grid = makeGrid() + evaluator.fireEvent('drought', 4, grid, 0, 0, 0) + const boostEffect = evaluator.getActiveEffects().find( + (e: ActiveEffect) => e.boostsCategory === 'wildfire' + ) + expect(boostEffect).toBeDefined() + expect(boostEffect!.boostsMultiplier).toBeCloseTo(2.0, 5) + expect(boostEffect!.turnsRemaining).toBe(5) + }) + + it('does NOT create wildfire boost for drought T3', () => { + const catalog: EventCatalog = { + drought: { + base_frequency: 0.05, + severity_weights: [30, 20, 15, 10, 5, 0, 0, 0, 0, 0], + tiers: { + '3': { name: 'Drought', radius: 3, moisture_loss: 0.1 }, + '4': { name: 'Severe Drought', radius: 5, moisture_loss: 0.2 }, + }, + }, + } + const evaluator = new EventEvaluator(catalog, CROSS_TRIGGERS, 42) + const grid = makeGrid() + evaluator.fireEvent('drought', 3, grid, 0, 0, 0) + const boostEffect = evaluator.getActiveEffects().find( + (e: ActiveEffect) => e.boostsCategory === 'wildfire' + ) + expect(boostEffect).toBeUndefined() + }) + }) + + describe('pandemic_t4_boosts_plague', () => { + it('creates plague frequency boost after pandemic T4', () => { + const catalog: EventCatalog = { + pandemic: { + base_frequency: 0.01, + severity_weights: [30, 25, 20, 15, 10, 0, 0, 0, 0, 0], + tiers: { '4': { name: 'Pandemic', radius: 99, fauna_loss: 0.1 } }, + }, + plague: { + base_frequency: 0.005, + severity_weights: [40, 30, 20, 10, 0, 0, 0, 0, 0, 0], + tiers: { '1': { name: 'Plague', radius: 5, fauna_loss: 0.05 } }, + }, + } + const evaluator = new EventEvaluator(catalog, CROSS_TRIGGERS, 42) + const grid = makeGrid() + evaluator.fireEvent('pandemic', 4, grid, 0, 0, 0) + const boostEffect = evaluator.getActiveEffects().find( + (e: ActiveEffect) => e.boostsCategory === 'plague' + ) + expect(boostEffect).toBeDefined() + expect(boostEffect!.boostsMultiplier).toBeCloseTo(1.5, 5) + expect(boostEffect!.turnsRemaining).toBe(5) + }) + }) + + describe('volcanic_t4_triggers_tsunami (probabilistic — 50% chance)', () => { + it('fires tsunami in approximately half of volcanic T4 invocations across many turns', () => { + const catalog: EventCatalog = { + volcanic: VOLCANIC_CATALOG.volcanic!, + tsunami: { + base_frequency: 0, + severity_weights: [60, 30, 10, 0, 0, 0, 0, 0, 0, 0], + tiers: { + '1': { name: 'Tsunami', radius: 3, moisture_loss: 0.2 }, + '2': { name: 'Great Tsunami', radius: 6, moisture_loss: 0.4 }, + }, + }, + } + const evaluator = new EventEvaluator(catalog, CROSS_TRIGGERS, 42) + const grid = makeGrid() + let tsunamiCount = 0 + for (let turn = 0; turn < 40; turn++) { + const events = evaluator.fireEvent('volcanic', 4, grid, turn, 0, 0) + if (events.some(e => e.type.startsWith('tsunami_'))) tsunamiCount++ + } + expect(tsunamiCount).toBeGreaterThan(0) + expect(tsunamiCount).toBeLessThan(40) + }) + }) + + describe('solar minimum cross-trigger — boost wiring', () => { + it('solar T5 boosts glacial base_frequency while active', () => { + const silentSolar: EventCatalog = { + solar: { ...SOLAR_CATALOG.solar!, base_frequency: 0 }, + glacial: SOLAR_CATALOG.glacial!, + } + const evaluator = new EventEvaluator(silentSolar, CROSS_TRIGGERS, 42) + const grid = makeGrid() + + const boostBefore = evaluator.getActiveEffects().find( + (e: ActiveEffect) => e.boostsCategory === 'glacial' + ) + expect(boostBefore).toBeUndefined() + + evaluator.fireEvent('solar', 5, grid, 0, 0, 0) + const boostAfter = evaluator.getActiveEffects().find( + (e: ActiveEffect) => e.boostsCategory === 'glacial' + ) + expect(boostAfter).toBeDefined() + expect(boostAfter!.boostsMultiplier).toBeCloseTo(1.5, 5) + }) + }) + + describe('PRNG determinism', () => { + it('same seed + same turn always produces identical event sequence', () => { + const evaluatorA = new EventEvaluator(VOLCANIC_CATALOG, CROSS_TRIGGERS, 12345) + const evaluatorB = new EventEvaluator(VOLCANIC_CATALOG, CROSS_TRIGGERS, 12345) + const gridA = makeGrid() + const gridB = makeGrid() + + const eventsA = evaluatorA.fireEvent('volcanic', 5, gridA, 100, 0, 0) + const eventsB = evaluatorB.fireEvent('volcanic', 5, gridB, 100, 0, 0) + + expect(eventsA).toEqual(eventsB) + for (let i = 0; i < gridA.tiles.length; i++) { + expect(gridA.tiles[i]!.sulfate_aerosol).toBeCloseTo(gridB.tiles[i]!.sulfate_aerosol, 10) + } + }) + + it('different seeds produce different PRNG sequences (checkWillFire diverges)', () => { + const evaluatorA = new EventEvaluator(SOLAR_CATALOG, {}, 1) + const evaluatorB = new EventEvaluator(SOLAR_CATALOG, {}, 999999) + let sameCount = 0 + const N = 100 + for (let t = 0; t < N; t++) { + if (evaluatorA.checkWillFire(t) === evaluatorB.checkWillFire(t)) sameCount++ + } + expect(sameCount).toBeLessThan(N) + }) + }) + + describe('EventEvaluator construction', () => { + it('skips categories with base_frequency=0 in checkWillFire', () => { + const mixedCatalog: EventCatalog = { + always_zero: { base_frequency: 0, severity_weights: [100, 0, 0, 0, 0, 0, 0, 0, 0, 0], tiers: { '1': { name: 'Zero' } } }, + very_rare: { base_frequency: 0.0001, severity_weights: [100, 0, 0, 0, 0, 0, 0, 0, 0, 0], tiers: { '1': { name: 'Rare' } } }, + } + const evaluator = new EventEvaluator(mixedCatalog, {}, 42) + let fires = 0 + for (let t = 0; t < 10; t++) { + if (evaluator.checkWillFire(t)) fires++ + } + expect(fires).toBeGreaterThanOrEqual(0) + }) + + it('handles empty catalog gracefully', () => { + const evaluator = new EventEvaluator({}, {}, 42) + const grid = makeGrid() + expect(evaluator.checkWillFire(0)).toBe(false) + expect(evaluator.hasActiveEffects()).toBe(false) + expect(evaluator.evaluateTurn(grid, 0)).toEqual([]) + }) + }) +})