test(simulation): ✅ Add tier-2 event system and simulation behavior test cases
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
b741f37612
commit
e98827c3d1
1 changed files with 183 additions and 0 deletions
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue