test(skills): persist M3 and M4 adversarial stress test suites on main

This commit is contained in:
troytt 2026-09-23 21:00:53 +00:00
parent a0567c2473
commit 617fdfec51
3 changed files with 2394 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,443 @@
/**
* Adversarial Stress & Correctness Verification Suite — Milestone M4
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
* Author: reviewer_lightning_m4 (teamwork_preview_reviewer / critic)
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
import { StateBus } from '../src/game/engine/state-bus.ts'
import { executeSUnitDmg, type CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
import { GameEngine } from '../src/game/engine.ts'
import {
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateEnergyShieldManaRatio,
calculateLightningMasteryBonusPct,
calculateChargedBoltDamage,
calculateNovaDamage,
calculateLightningDamage,
calculateChainLightningDamage,
calculateSkillDamage,
BATCH1_SKILLS,
CANONICAL_113C_MISSILES,
} from '../src/game/skills.ts'
import { getSkillRange, getSkillMana } from '../src/scene/act-scene.ts'
describe('Adversarial Stress Test: Milestone M4 (Skills 57, 58, 63)', () => {
// =========================================================================
// 1. Thunder Storm (57) Adversarial Stress
// =========================================================================
describe('1. Thunder Storm (57) Stress Testing', () => {
it('stresses 5-band piecewise progression across 1..99 levels', () => {
// Band 1: lvl 1..8 (+10/+10)
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
// Band 2: lvl 9..16 (+10/+10)
expect(calculateThunderStormDamage(9)).toEqual({ min: 81, max: 180 })
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
// Band 3: lvl 17..22 (+11/+11)
expect(calculateThunderStormDamage(17)).toEqual({ min: 162, max: 261 })
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
// Band 4: lvl 23..28 (+11/+11)
expect(calculateThunderStormDamage(23)).toEqual({ min: 228, max: 327 })
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
// Band 5: lvl 29+ (+11/+11)
expect(calculateThunderStormDamage(29)).toEqual({ min: 294, max: 393 })
expect(calculateThunderStormDamage(40)).toEqual({ min: 415, max: 514 })
expect(calculateThunderStormDamage(99)).toEqual({ min: 1064, max: 1163 })
})
it('stresses cadence interval hyperbolic decay and floor clamping at 25 frames', () => {
// Interval formula: max(25, 125 - floor(110 * slvl / (slvl + 6)))
expect(calculateThunderStormInterval(1)).toBe(110)
expect(calculateThunderStormInterval(5)).toBe(75)
expect(calculateThunderStormInterval(10)).toBe(57)
expect(calculateThunderStormInterval(20)).toBe(41)
expect(calculateThunderStormInterval(30)).toBe(34)
expect(calculateThunderStormInterval(40)).toBe(30)
expect(calculateThunderStormInterval(50)).toBe(27)
expect(calculateThunderStormInterval(60)).toBe(25) // exactly reaches 25 floor!
expect(calculateThunderStormInterval(70)).toBe(25)
expect(calculateThunderStormInterval(100)).toBe(25)
expect(calculateThunderStormInterval(999)).toBe(25)
})
it('stresses edge inputs for duration, interval, damage', () => {
expect(calculateThunderStormDuration(0)).toBe(800)
expect(calculateThunderStormDuration(-99)).toBe(800)
expect(calculateThunderStormDuration(NaN as any)).toBe(800)
expect(calculateThunderStormInterval(0)).toBe(110)
expect(calculateThunderStormInterval(-10)).toBe(110)
expect(calculateThunderStormInterval(NaN as any)).toBe(110)
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
expect(calculateThunderStormDamage(-10)).toEqual({ min: 1, max: 100 })
expect(calculateThunderStormDamage(NaN as any)).toEqual({ min: 1, max: 100 })
})
it('stresses runtime GameEngine strike targeting nearest monster within 340px', () => {
const terrain = {
widthPx: 1280,
heightPx: 960,
overlap: (_x: number, _y: number) => 0,
isMissileBlocked: () => false,
} as any
const engine = new GameEngine(terrain, {
spawn: { x: 500, y: 500 },
monsterCount: 0,
stats: [],
skills: ['attack'] as any,
xpTable: [0, 500],
combatOptions: {
playerSpeed: 100,
playerReach: 40,
playerCooldownTicks: 15,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 75,
disableMonsterAggro: true,
},
npcRingFallback: false,
} as any)
// Player at (500, 500) with active Thunder Storm (interval 1 tick for testing)
engine.world.player.x = 500
engine.world.player.y = 500
engine.world.player.thunderstormTicks = 100
engine.world.player.thunderstormInterval = 1
engine.world.player.thunderstormNextStrike = 1
engine.world.player.thunderstormDamage = 200
// Add three monsters:
// M0: dead monster at (510, 500) (dist 10) - must be ignored!
// M1: live monster at (600, 500) (dist 100) - closest valid!
// M2: live monster at (750, 500) (dist 250) - farther!
// M3: live monster at (900, 500) (dist 400) - outside 340px!
engine.world.monsters = [
{ index: 0, id: 'm0', x: 510, y: 500, hp: 0, maxHp: 500, state: 'dead', stats: { name: 'm0', id: 'm0', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
{ index: 1, id: 'm1', x: 600, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm1', id: 'm1', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
{ index: 2, id: 'm2', x: 750, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm2', id: 'm2', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
{ index: 3, id: 'm3', x: 900, y: 500, hp: 500, maxHp: 500, state: 'idle', stats: { name: 'm3', id: 'm3', speed: 50, damage: 10, cooldownTicks: 25 }, cooldown: 0 } as any,
]
// Tick engine by 1 frame
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
picking: false,
dropping: null,
digits: [],
} as any)
// M1 (closest alive inside 340px) should take 200 damage!
expect(engine.world.monsters[1]?.hp).toBe(300)
// M2 should take 0 damage
expect(engine.world.monsters[2]?.hp).toBe(500)
// M3 should take 0 damage
expect(engine.world.monsters[3]?.hp).toBe(500)
// Overlay light_cast_2 should spawn on M1
const ov = engine.overlays.find(o => o.overlayName === 'light_cast_2')
expect(ov).toBeDefined()
expect(ov?.x).toBe(600)
expect(ov?.y).toBe(500)
})
})
// =========================================================================
// 2. Energy Shield (58) Adversarial Stress
// =========================================================================
describe('2. Energy Shield (58) Stress Testing', () => {
it('stresses 95% cap across high skill levels', () => {
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(60)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(999)).toBe(95)
})
it('stresses Telekinesis synergy ratio boundaries and soft point immutability', () => {
// 0 hard points -> 2.0
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
// 1..20 hard points -> 1/16 less per point
expect(calculateEnergyShieldManaRatio(1)).toBe(31 / 16)
expect(calculateEnergyShieldManaRatio(8)).toBe(24 / 16)
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
// 21+ points -> strictly clamped at 0.75
expect(calculateEnergyShieldManaRatio(21)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(40)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(99)).toBe(0.75)
// Negative points -> strictly clamped at 2.0
expect(calculateEnergyShieldManaRatio(-1)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(-50)).toBe(2.0)
})
it('stresses pre-resistance vs post-resistance mathematical difference', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 2000 * FIXED_ONE,
mana: 2000 * FIXED_ONE,
lightresist: 75, // 75% Lightning Resistance
})
// TK blvl = 16 (1.0 ratio)
defenderStats.setBaseSkillLevel(43, 16)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
defenderBus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
// 1000 raw lightning damage
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49,
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 1000 * FIXED_ONE,
elemMax256: 1000 * FIXED_ONE,
})
// Ground truth 1.13c Pre-Resistance order:
// 1. Raw: 1000 ltng.
// 2. ES absorbs 75% = 750 damage.
// 3. Mana drained: 750 * 1.0 = 750 mana.
// 4. Remaining raw ltng: 250.
// 5. 75% LightRes mitigates remaining 250 down to 62.5 (250 * 0.25).
// 6. 256-fixed-point rounding: Math.ceil(16000 / 256) = 63.
expect(defenderStats.getMana()).toBe(1250) // 2000 - 750
expect(res.totalDamage).toBe(63)
expect(defenderStats.getHp()).toBe(938)
})
it('stresses mixed physical and poison damage: physical is absorbed while poison 100% bypasses', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
stats.setBaseSkillLevel(43, 16) // 1.0 ratio
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 100 * FIXED_ONE,
elemType: 'pois',
isPoison: true,
})
// Physical: 75% of 200 = 150 absorbed, 50 remains
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
// Poison: 0% absorbed! 100 remains
expect(out.absorbedElem256).toBe(0)
// Mana drained: only 150 mana (from physical), 0 from poison!
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
expect(stats.getMana()).toBe(850)
})
it('stresses extreme overkill damage and shield collapse', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 5000 * FIXED_ONE,
hitpoints: 5000 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 10 * FIXED_ONE, // only 10 mana left!
})
stats.setBaseSkillLevel(43, 0) // 2.0 ratio: 10 mana can only absorb 5 damage
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
// Incoming: 50,000 damage
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 50000 * FIXED_ONE,
incomingElem256: 0,
})
// 10 mana / 2.0 = 5 damage absorbed
expect(out.absorbedPhys256).toBe(5 * FIXED_ONE)
expect(out.manaDrained256).toBe(10 * FIXED_ONE)
expect(stats.getMana()).toBe(0)
// Shield collapsed
expect(bus.hasState('energyshield')).toBe(false)
expect(bus.hasState(60)).toBe(false)
})
it('stresses 0 mana available: 0 absorbed, shield collapses instantly', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 0, // 0 mana!
})
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20,
})
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 500 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(0)
expect(out.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(0)
expect(bus.hasState('energyshield')).toBe(false)
})
})
// =========================================================================
// 3. Lightning Mastery (63) Adversarial Stress
// =========================================================================
describe('3. Lightning Mastery (63) Stress Testing', () => {
it('stresses formula across extreme skill levels', () => {
expect(calculateLightningMasteryBonusPct(1)).toBe(50)
expect(calculateLightningMasteryBonusPct(10)).toBe(158)
expect(calculateLightningMasteryBonusPct(20)).toBe(278)
expect(calculateLightningMasteryBonusPct(40)).toBe(518)
expect(calculateLightningMasteryBonusPct(99)).toBe(50 + 98 * 12) // 1226%
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
expect(calculateLightningMasteryBonusPct(-5)).toBe(0)
})
it('proves multiplicative scaling is strictly greater than additive scaling for all 5 skills', () => {
// 1. Charged Bolt: Base * (1 + 1.20) * (1 + 2.78) vs Base * (1 + 1.20 + 2.78)
const cbBase = calculateChargedBoltDamage(20)
const cbBoth = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const cbAdditiveMin = Math.floor(cbBase.min * (1 + 1.20 + 2.78))
const cbAdditiveMax = Math.floor(cbBase.max * (1 + 1.20 + 2.78))
expect(cbBoth.min).toBeGreaterThan(cbAdditiveMin)
expect(cbBoth.max).toBeGreaterThan(cbAdditiveMax)
// 2. Lightning: Base * (1 + 1.60) * (1 + 2.78) vs Base * (1 + 1.60 + 2.78)
const ltBase = calculateLightningDamage(20)
const ltBoth = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
const ltAdditiveMax = Math.floor(ltBase.max * (1 + 1.60 + 2.78))
expect(ltBoth.max).toBeGreaterThan(ltAdditiveMax)
// 3. Chain Lightning: Base * (1 + 0.80) * (1 + 2.78) vs Base * (1 + 0.80 + 2.78)
const clBase = calculateChainLightningDamage(20)
const clBoth = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const clAdditiveMax = Math.floor(clBase.max * (1 + 0.80 + 2.78))
expect(clBoth.max).toBeGreaterThan(clAdditiveMax)
// 4. Nova: Base * (1 + 2.78)
const novaBase = calculateNovaDamage(20)
const novaMastered = calculateNovaDamage(20, { lightningMastery: 20 })
expect(novaMastered.min).toBe(Math.floor(novaBase.min * 3.78))
expect(novaMastered.max).toBe(Math.floor(novaBase.max * 3.78))
// 5. Thunder Storm: Base * (1 + 2.78)
const tsBase = calculateThunderStormDamage(20)
const tsMastered = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(tsMastered.min).toBe(Math.floor(tsBase.min * 3.78))
expect(tsMastered.max).toBe(Math.floor(tsBase.max * 3.78))
})
it('stresses elemental isolation in executeSUnitDmg (Cold, Fire, Poison, Magic untouched)', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
// Level 40 Lightning Mastery (+518%)
attackerStats.setBaseSkillLevel(63, 40)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
const attackerCtx: CombatUnitContext = { id: 'a', name: 'A', statList: attackerStats, stateBus: attackerBus }
const defenderCtx: CombatUnitContext = { id: 'd', name: 'D', statList: defenderStats, stateBus: defenderBus }
// Fire test
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47,
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resFire.totalDamage).toBe(100)
// Cold test
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 59,
attackKind: 'spell',
autoHit: true,
elemType: 'cold',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resCold.totalDamage).toBe(100)
// Poison test
const resPois = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 1,
attackKind: 'spell',
autoHit: true,
elemType: 'pois',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resPois.totalDamage).toBe(100)
// Lightning test -> should be scaled by +518%: 100 * 6.18 = 618!
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49,
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resLtng.totalDamage).toBe(618)
})
})
// =========================================================================
// 4. Invariants & System Integrity
// =========================================================================
describe('4. Invariants & System Integrity', () => {
it('strictly preserves BATCH1_SKILLS length at 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('verifies thunderstorm1 canonical missile integrity', () => {
const msl = CANONICAL_113C_MISSILES['thunderstorm1']
expect(msl).toBeDefined()
expect(msl?.id).toBe(166)
expect(msl?.celFile).toBe('LightningboltBig')
expect(msl?.animLen).toBe(8)
expect(msl?.trans).toBe(1)
})
})
})

View File

@ -0,0 +1,897 @@
/**
* Adversarial Stress & Correctness Verification Suite — Milestone M4
* Skills: Thunder Storm (#057), Energy Shield (#058), Lightning Mastery (#063)
* Author: challenger_lightning_m4 (teamwork_preview_challenger)
* Ground Truth: Diablo II v1.13c (D2Game.dll, D2Common.dll, Skills.txt, Missiles.txt)
*
* Test Dimensions:
* 1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite:
* - Fuzz slvl 1 to 50: verify cadence strictly follows Math.max(25, 125 - Math.floor(110*slvl/(slvl+6))).
* - Verify nearest monster acquisition strictly selects closest target <= 17 subtiles (340px) and ignores dead, out-of-range, or invalid targets.
* - Verify 0-synergy invariant and multiplicative scaling with Lightning Mastery.
* 2. Energy Shield Mechanics & Edge Cases:
* - Absorption curve fuzzing: verify 20% at lvl 1, 55% at lvl 8, 71% at lvl 16, 75% at lvl 20, 95% hard cap at lvl 40.
* - Telekinesis hard synergy ratio: verify ratio ranges from 2.0 (0 hard points) down to 0.75 (20 hard points). Verify soft points (+allskills) DO NOT decrease ratio.
* - Damage absorption order: raw damage absorbed BEFORE elemental/physical resistances are applied to player.
* - Poison & Open Wounds bypass: verify poison damage and open wounds deal direct HP damage with 0 mana drain and 0 absorption.
* - Mana depletion & collapse: when damage exceeds available mana, verify mana drops to 0, excess unabsorbed damage spills over to HP, and state 'energyshield' / state 60 is immediately removed.
* 3. Lightning Mastery Multiplicative Scaling:
* - Verify scaling is multiplicative with synergies: Base * (1 + Synergy/100) * (1 + Mastery/100), NOT additive Base * (1 + (Synergy + Mastery)/100).
* - Test across Charged Bolt, Lightning, Chain Lightning, Nova, and Thunder Storm.
* - Verify UnitStatList accrues passive_ltng_mastery correctly.
* 4. Engine & State Bus Integrity:
* - Invariant check: BATCH1_SKILLS length strictly 38.
* - Clean up any scratch files after testing.
*/
import { describe, expect, it } from 'vitest'
import {
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateEnergyShieldManaRatio,
calculateLightningMasteryBonusPct,
calculateChargedBoltDamage,
calculateLightningDamage,
calculateChainLightningDamage,
calculateNovaDamage,
calculateSkillDamage,
BATCH1_SKILLS,
CANONICAL_113C_MISSILES,
} from '../src/game/skills.ts'
import {
executeSUnitDmg,
type CombatUnitContext,
} from '../src/game/engine/combat-pipeline.ts'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts'
import { StateBus } from '../src/game/engine/state-bus.ts'
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts'
import { getSkillRange, getSkillMana, castSkill } from '../src/scene/act-scene.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import { OVERLAY_METAS } from '../src/render/overlays-meta.ts'
import { MISSILE_METAS } from '../src/render/missiles-meta.ts'
const DUMMY_INPUT = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
function createHarnessEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) {
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: () => 0,
isMissileBlocked: () => false,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 0,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 100
engine.world.player.y = 100
engine.world.player.hp = 1000
engine.world.player.maxHp = 1000
engine.world.player.mana = 1000
engine.world.player.maxMana = 1000
engine.world.player.cooldown = 0
if (monsters) {
engine.world.monsters.length = 0
for (let i = 0; i < monsters.length; i++) {
const m = monsters[i]!
const isUndead = m.isUndead ?? false
engine.world.monsters.push({
index: i,
stats: {
id: isUndead ? 'skeleton' : 'fallen',
name: m.name ?? (isUndead ? 'Skeleton' : 'Fallen'),
hp: m.hp ?? 100,
damage: 20,
cooldownTicks: 25,
reach: 25,
aggroRadius: 200,
speed: 0,
xp: 10,
},
x: m.x,
y: m.y,
hp: m.hp ?? 100,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
...(isUndead ? { isUndead: true } : {}),
})
}
}
return engine
}
describe('Milestone M4 Adversarial Stress & Verification Suite (Challenger Lightning M4)', () => {
// =========================================================================
// 1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite
// =========================================================================
describe('1. Thunder Storm Cadence & Hyperbolic Decay Stress Suite', () => {
it('fuzzes slvl from 1 to 50: cadence strictly follows Math.max(25, 125 - Math.floor(110*slvl/(slvl+6)))', () => {
let previousInterval = Infinity
for (let slvl = 1; slvl <= 50; slvl++) {
const expected = Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6)))
const actual = calculateThunderStormInterval(slvl)
expect(actual).toBe(expected)
// Monotonic decay check: higher slvl must yield shorter or equal interval
expect(actual).toBeLessThanOrEqual(previousInterval)
previousInterval = actual
}
// Checkpoint sanity checks
expect(calculateThunderStormInterval(1)).toBe(110) // 125 - 15 = 110 (4.4s)
expect(calculateThunderStormInterval(2)).toBe(98) // 125 - 27 = 98 (3.92s)
expect(calculateThunderStormInterval(5)).toBe(75) // 125 - 50 = 75 (3.0s)
expect(calculateThunderStormInterval(10)).toBe(57) // 125 - 68 = 57 (2.28s)
expect(calculateThunderStormInterval(20)).toBe(41) // 125 - 84 = 41 (1.64s)
expect(calculateThunderStormInterval(30)).toBe(34) // 125 - 91 = 34 (1.36s)
expect(calculateThunderStormInterval(40)).toBe(30) // 125 - 95 = 30 (1.20s)
expect(calculateThunderStormInterval(50)).toBe(27) // 125 - 98 = 27 (1.08s)
// Extreme boundary clamping (floor 25 frames reached at slvl >= 60)
expect(calculateThunderStormInterval(55)).toBe(26) // 125 - 99 = 26
expect(calculateThunderStormInterval(60)).toBe(25) // 125 - 100 = 25 (exact floor threshold)
expect(calculateThunderStormInterval(99)).toBe(25)
expect(calculateThunderStormInterval(500)).toBe(25)
expect(calculateThunderStormInterval(0)).toBe(110)
expect(calculateThunderStormInterval(-10)).toBe(110)
})
it('fuzzes duration across slvl 1 to 50: strictly follows 800 + (slvl - 1) * 200 frames', () => {
for (let slvl = 1; slvl <= 50; slvl++) {
const expected = 800 + (slvl - 1) * 200
expect(calculateThunderStormDuration(slvl)).toBe(expected)
}
expect(calculateThunderStormDuration(1)).toBe(800) // 32s
expect(calculateThunderStormDuration(10)).toBe(2600) // 104s
expect(calculateThunderStormDuration(20)).toBe(4600) // 184s
expect(calculateThunderStormDuration(40)).toBe(8600) // 344s
expect(calculateThunderStormDuration(0)).toBe(800)
expect(calculateThunderStormDuration(-5)).toBe(800)
})
it('verifies 0-synergy invariant on Thunder Storm: synergies provide zero bonus in 1.13c', () => {
for (let slvl = 1; slvl <= 30; slvl += 5) {
const base = calculateThunderStormDamage(slvl)
// Adversarial synergy inputs
const withFakeSynergies = calculateThunderStormDamage(slvl, {
chargedBolt: 20,
lightning: 20,
nova: 20,
chainLightning: 20,
} as any)
expect(withFakeSynergies.min).toBe(base.min)
expect(withFakeSynergies.max).toBe(base.max)
}
})
it('verifies 5-band piecewise base damage scaling for Thunder Storm', () => {
// Level 1: 1 - 100
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
// Level 8: +10/lvl (70) -> 71 - 170
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
// Level 16: +10/lvl (80) -> 151 - 250
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
// Level 22: +11/lvl (66) -> 217 - 316
expect(calculateThunderStormDamage(22)).toEqual({ min: 217, max: 316 })
// Level 28: +11/lvl (66) -> 283 - 382
expect(calculateThunderStormDamage(28)).toEqual({ min: 283, max: 382 })
// Level 35: +11/lvl (77) -> 360 - 459
expect(calculateThunderStormDamage(35)).toEqual({ min: 360, max: 459 })
})
it('verifies multiplicative scaling with Lightning Mastery on Thunder Storm', () => {
// Level 20 TS base: 195 - 294
const base = calculateThunderStormDamage(20)
expect(base).toEqual({ min: 195, max: 294 })
// With LM 1 (+50%): floor(195 * 1.5) = 292, floor(294 * 1.5) = 441
expect(calculateThunderStormDamage(20, { lightningMastery: 1 })).toEqual({ min: 292, max: 441 })
// With LM 20 (+278%): floor(195 * 3.78) = 737, floor(294 * 3.78) = 1111
expect(calculateThunderStormDamage(20, { lightningMastery: 20 })).toEqual({ min: 737, max: 1111 })
// With LM 40 (+518%): floor(195 * 6.18) = 1205, floor(294 * 6.18) = 1816
expect(calculateThunderStormDamage(20, { lightningMastery: 40 })).toEqual({ min: 1205, max: 1816 })
// Overload check: 3rd parameter
expect(calculateThunderStormDamage(20, undefined, 20)).toEqual({ min: 737, max: 1111 })
})
it('adversarially stress-tests nearest monster acquisition in GameEngine runtime simulation', () => {
// Player is at (100, 100)
const engine = createHarnessEngine([
{ x: 180, y: 100, hp: 500, name: 'CloseMonster' }, // dist 80px <= 340px
{ x: 250, y: 100, hp: 500, name: 'MidMonster' }, // dist 150px <= 340px
{ x: 380, y: 100, hp: 500, name: 'FarMonster' }, // dist 280px <= 340px
{ x: 441, y: 100, hp: 500, name: 'OutOfRangeMonster' }, // dist 341px > 340px
])
const player = engine.world.player
player.thunderstormTicks = 1000
player.thunderstormInterval = 57 // slvl 10 interval
player.thunderstormNextStrike = 10 // strike in 10 ticks
player.thunderstormDamage = 150
const collectedHits: { x: number; y: number; amount: number }[] = []
// Advance 9 ticks: no strike yet
for (let t = 0; t < 9; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
expect(collectedHits.length).toBe(0)
expect(engine.world.monsters[0]!.hp).toBe(500)
expect(engine.world.monsters[1]!.hp).toBe(500)
expect(engine.world.monsters[2]!.hp).toBe(500)
expect(engine.world.monsters[3]!.hp).toBe(500)
// Tick 10: strike occurs! Must select closest monster (Monster 0 at 80px)
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
expect(collectedHits.length).toBeGreaterThanOrEqual(1)
expect(collectedHits[0]!.x).toBe(180)
expect(collectedHits[0]!.amount).toBe(150)
expect(engine.world.monsters[0]!.hp).toBe(350) // took 150 damage
expect(engine.world.monsters[1]!.hp).toBe(500)
expect(engine.world.monsters[2]!.hp).toBe(500)
expect(engine.world.monsters[3]!.hp).toBe(500)
expect(player.thunderstormNextStrike).toBe(57) // reset to interval
// Kill Monster 0
engine.world.monsters[0]!.hp = 0
engine.world.monsters[0]!.state = 'dead'
// Advance 57 ticks for next strike
const hitsBeforeSecondStrike = collectedHits.length
for (let t = 0; t < 57; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
expect(collectedHits.length).toBeGreaterThan(hitsBeforeSecondStrike)
// Dead Monster 0 must be ignored, next closest is Monster 1 (150px)
expect(collectedHits[hitsBeforeSecondStrike]!.x).toBe(250)
expect(engine.world.monsters[1]!.hp).toBe(350) // took 150 damage
expect(engine.world.monsters[2]!.hp).toBe(500)
expect(engine.world.monsters[3]!.hp).toBe(500)
// Kill Monster 1 and Monster 2
engine.world.monsters[1]!.hp = 0
engine.world.monsters[1]!.state = 'dead'
engine.world.monsters[2]!.hp = 0
engine.world.monsters[2]!.state = 'dead'
// Advance 57 ticks: Monster 3 is at 341px (out of range, limit is 340px)
const hitsBeforeOutOfRange = collectedHits.length
for (let t = 0; t < 57; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
// Out of range monster MUST NOT take damage, no new hit events
expect(collectedHits.length).toBe(hitsBeforeOutOfRange)
expect(engine.world.monsters[3]!.hp).toBe(500)
// Add a monster exactly at 340px: x = 440, y = 100
engine.world.monsters.push({
index: 4,
stats: {
id: 'fallen',
name: 'ExactBoundaryMonster',
hp: 200,
damage: 10,
cooldownTicks: 25,
reach: 25,
aggroRadius: 200,
speed: 0,
xp: 10,
},
x: 440,
y: 100,
hp: 200,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
// Advance 57 ticks: Monster 4 is within <= 340px boundary and must be struck!
for (let t = 0; t < 57; t++) {
engine.tick(DUMMY_INPUT)
for (const e of engine.world.events) {
if (e.kind === 'monsterHit') collectedHits.push(e as any)
}
}
expect(collectedHits.length).toBeGreaterThan(hitsBeforeOutOfRange)
expect(collectedHits[collectedHits.length - 1]!.x).toBe(440)
expect(engine.world.monsters[4]!.hp).toBe(50) // 200 - 150 = 50
})
})
// =========================================================================
// 2. Energy Shield Mechanics & Edge Cases
// =========================================================================
describe('2. Energy Shield Mechanics & Edge Cases', () => {
it('fuzzes absorption curve slvl 1 to 50: verifies 20% at lvl 1, 55% at lvl 8, 71% at lvl 16, 75% at lvl 20, 95% hard cap at lvl 40', () => {
for (let slvl = 1; slvl <= 50; slvl++) {
let expected = 20
if (slvl > 1) {
const b1 = Math.min(slvl - 1, 7)
expected += b1 * 5
if (slvl > 8) {
const b2 = Math.min(slvl - 8, 8)
expected += b2 * 2
}
if (slvl > 16) {
const b3 = slvl - 16
expected += b3 * 1
}
}
expected = Math.min(95, expected)
expect(calculateEnergyShieldAbsorbPct(slvl)).toBe(expected)
}
// Checkpoint validations
expect(calculateEnergyShieldAbsorbPct(1)).toBe(20)
expect(calculateEnergyShieldAbsorbPct(8)).toBe(55)
expect(calculateEnergyShieldAbsorbPct(16)).toBe(71)
expect(calculateEnergyShieldAbsorbPct(20)).toBe(75)
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
// Clamped above 40
expect(calculateEnergyShieldAbsorbPct(41)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(50)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(0)).toBe(20)
expect(calculateEnergyShieldAbsorbPct(-5)).toBe(20)
})
it('fuzzes Telekinesis hard synergy ratio: strictly (32 - min(20, blvl))/16 from 2.0 down to 0.75 floor', () => {
for (let tk = 0; tk <= 40; tk++) {
const expected = (32 - Math.min(20, Math.max(0, tk))) / 16
expect(calculateEnergyShieldManaRatio(tk)).toBe(expected)
}
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0)
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5)
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0)
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(30)).toBe(0.75) // clamped to floor 0.75
})
it('verifies soft points (+allskills, +bonusSkill, +classSkills) DO NOT decrease Telekinesis ratio in StateBus', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
// 16 hard points in Telekinesis (43) => authentic ratio = 1.0
stats.setBaseSkillLevel(43, 16)
// Add heavy soft skills bonuses: +20 all skills, +8 single skill bonus
stats.setAllSkillsBonus(20)
stats.setBonusSkillLevel(43, 8)
// Effective skill level is now 16 + 20 + 8 = 44!
expect(stats.getEffectiveSkillLevel(43)).toBe(44)
expect(stats.getBaseSkillLevel(43)).toBe(16)
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
// Trigger 200 incoming physical damage:
// Desired absorb = 75% of 200 = 150 damage.
// If soft skills counted (effective lvl 44 -> max clamped 20), ratio would be 0.75, draining 150 * 0.75 = 112.5 mana.
// But 1.13c strictly uses hard points (blvl = 16 -> ratio = 1.0), so mana drained MUST be 150!
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
expect(stats.getMana()).toBe(850) // 1000 - 150 = 850
})
it('empirically proves damage absorption order: raw damage absorbed BEFORE player resistances are applied', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
fireresist: 75, // 75% Fire Resistance
})
defenderStats.setBaseSkillLevel(43, 16) // ratio = 1.0
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
defenderBus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
const attackerCtx: CombatUnitContext = {
id: 'attacker-1',
name: 'Attacker',
statList: attackerStats,
stateBus: attackerBus,
}
const defenderCtx: CombatUnitContext = {
id: 'defender-1',
name: 'Sorceress',
statList: defenderStats,
stateBus: defenderBus,
}
// 400 raw Fire Damage spell
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47,
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 400 * FIXED_ONE,
elemMax256: 400 * FIXED_ONE,
})
// Ground truth logic:
// 1. Raw Fire = 400.
// 2. ES absorbs 75% of raw = 300 damage.
// 3. Mana drained: 300 * 1.0 = 300. Mana becomes 700.
// 4. Remaining raw fire = 100.
// 5. 75% Fire Resistance mitigates 100 to 25.
// 6. Net HP loss: 25. Defender HP becomes 975.
expect(defenderStats.getMana()).toBe(700)
expect(res.totalDamage).toBe(25)
expect(defenderStats.getHp()).toBe(975)
// Contrast with incorrect absorb-after-resistance order:
// In incorrect order, 400 would be mitigated to 100 first, then ES absorbs 75, draining only 75 mana.
// We verify mana drained is strictly 300!
})
it('verifies pre-resistance absorption with Physical Damage and Damage Resist (DR%)', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
damageresist: 50, // 50% Physical Damage Reduction (DR%)
})
defenderStats.setBaseSkillLevel(43, 20) // ratio = 0.75
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
defenderBus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
const attackerCtx: CombatUnitContext = {
id: 'attacker-1',
name: 'Attacker',
statList: attackerStats,
stateBus: attackerBus,
}
const defenderCtx: CombatUnitContext = {
id: 'defender-1',
name: 'Sorceress',
statList: defenderStats,
stateBus: defenderBus,
}
// Attack: 400 Physical Damage
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 1, // Normal Attack
attackKind: 'melee',
autoHit: true,
srcDam: 0,
flatPhysMin256: 400 * FIXED_ONE,
flatPhysMax256: 400 * FIXED_ONE,
})
// Ground truth logic:
// 1. Raw Physical = 400.
// 2. ES absorbs 75% of raw = 300 damage.
// 3. Mana drained: 300 * 0.75 = 225 mana.
// 4. Remaining raw physical = 100.
// 5. 50% DR mitigates remaining 100 to 50 net HP damage.
expect(defenderStats.getMana()).toBe(775) // 1000 - 225
expect(res.totalDamage).toBe(50)
expect(defenderStats.getHp()).toBe(950)
})
it('empirically verifies Poison and Open Wounds strictly bypass Energy Shield directly to HP', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 40, // 95% absorb!
})
// Case 1: elemType === 'pois'
const outPois = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 300 * FIXED_ONE,
elemType: 'pois',
})
expect(outPois.absorbedElem256).toBe(0)
expect(outPois.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
// Case 2: isPoison flag true
const outPoisFlag = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 300 * FIXED_ONE,
isPoison: true,
})
expect(outPoisFlag.absorbedElem256).toBe(0)
expect(outPoisFlag.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
// Case 3: isOpenWounds flag true
const outOW = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 250 * FIXED_ONE,
incomingElem256: 0,
isOpenWounds: true,
})
expect(outOW.absorbedPhys256).toBe(0)
expect(outOW.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
// Case 4: Composite packet: 200 physical + 100 poison
const outComposite = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 100 * FIXED_ONE,
elemType: 'pois',
isPoison: true,
})
// Physical: 95% of 200 = 190 absorbed.
// Poison: 0 absorbed.
expect(outComposite.absorbedPhys256).toBe(190 * FIXED_ONE)
expect(outComposite.absorbedElem256).toBe(0)
expect(outComposite.manaDrained256).toBe(380 * FIXED_ONE) // 190 * 2.0 ratio (tk = 0)
expect(stats.getMana()).toBe(620)
})
it('empirically verifies mana depletion, shield collapse, and spillover to HP', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 60 * FIXED_ONE, // Only 60 mana available!
})
stats.setBaseSkillLevel(43, 0) // tk = 0 -> ratio = 2.0 (needs 2 mana per 1 damage absorbed)
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
// Incoming: 200 physical damage.
// Desired absorb: 150 damage, requiring 300 mana.
// Available mana: 60 mana.
// Actual absorb: Math.floor(60 / 2.0) = 30 damage.
// Mana drained: exactly 60 -> drops to 0.
// Remainder unabsorbed damage: 200 - 30 = 170 damage spills over.
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(30 * FIXED_ONE)
expect(out.manaDrained256).toBe(60 * FIXED_ONE)
expect(stats.getMana()).toBe(0)
// State bus MUST remove both 'energyshield' and state 60 immediately
expect(bus.hasState('energyshield')).toBe(false)
expect(bus.hasState(60)).toBe(false)
// Follow-up hit after collapse: 200 physical damage
const out2 = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
// Since shield is collapsed, 0 is absorbed!
expect(out2.absorbedPhys256).toBe(0)
expect(out2.manaDrained256).toBe(0)
})
})
// =========================================================================
// 3. Lightning Mastery Multiplicative Scaling
// =========================================================================
describe('3. Lightning Mastery Multiplicative Scaling', () => {
it('verifies multiplicative scaling: Base * (1 + Syn/100) * (1 + LM/100) strictly exceeds additive across skills', () => {
// 1. Charged Bolt (38)
// Level 20 base: 25 - 27
const cbBase = calculateChargedBoltDamage(20)
const cbSynBonus = 20 * 6 // +120% from 20 hard points in Lightning
const cbLmBonus = calculateLightningMasteryBonusPct(20) // +278%
const cbMult = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const expectedCbMin = Math.floor(cbBase.min * (1 + cbSynBonus / 100) * (1 + cbLmBonus / 100))
const expectedCbMax = Math.floor(cbBase.max * (1 + cbSynBonus / 100) * (1 + cbLmBonus / 100))
expect(cbMult.min).toBe(expectedCbMin)
expect(cbMult.max).toBe(expectedCbMax)
// Additive counter-check: Base * (1 + 1.20 + 2.78) = Base * 4.98
const additiveCbMin = Math.floor(cbBase.min * (1 + (cbSynBonus + cbLmBonus) / 100))
expect(cbMult.min).toBeGreaterThan(additiveCbMin)
// 2. Lightning (49)
// Level 20 base: 1 - 272
const lBase = calculateLightningDamage(20)
const lSynBonus = 20 * 8 // +160% from Chain Lightning
const lMult = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
const expectedLMin = Math.floor(lBase.min * (1 + lSynBonus / 100) * (1 + cbLmBonus / 100))
const expectedLMax = Math.floor(lBase.max * (1 + lSynBonus / 100) * (1 + cbLmBonus / 100))
expect(lMult.min).toBe(expectedLMin)
expect(lMult.max).toBe(expectedLMax)
const additiveLMax = Math.floor(lBase.max * (1 + (lSynBonus + cbLmBonus) / 100))
expect(lMult.max).toBeGreaterThan(additiveLMax)
// 3. Chain Lightning (53)
// Level 20 base: 1 - 188
const clBase = calculateChainLightningDamage(20)
const clSynBonus = 20 * 4 // +80% from Lightning
const clMult = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
const expectedClMin = Math.floor(clBase.min * (1 + clSynBonus / 100) * (1 + cbLmBonus / 100))
const expectedClMax = Math.floor(clBase.max * (1 + clSynBonus / 100) * (1 + cbLmBonus / 100))
expect(clMult.min).toBe(expectedClMin)
expect(clMult.max).toBe(expectedClMax)
const additiveClMax = Math.floor(clBase.max * (1 + (clSynBonus + cbLmBonus) / 100))
expect(clMult.max).toBeGreaterThan(additiveClMax)
// 4. Nova (48) (0 synergies in 1.13c; LM scales base damage multiplicatively)
const nBase = calculateNovaDamage(20)
const nMult = calculateNovaDamage(20, { lightningMastery: 20 })
expect(nMult.min).toBe(Math.floor(nBase.min * (1 + cbLmBonus / 100)))
expect(nMult.max).toBe(Math.floor(nBase.max * (1 + cbLmBonus / 100)))
// 5. Thunder Storm (57) (0 synergies in 1.13c; LM scales base damage multiplicatively)
const tsBase = calculateThunderStormDamage(20)
const tsMult = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(tsMult.min).toBe(Math.floor(tsBase.min * (1 + cbLmBonus / 100)))
expect(tsMult.max).toBe(Math.floor(tsBase.max * (1 + cbLmBonus / 100)))
})
it('verifies UnitStatList dynamically accrues passive_ltng_mastery with soft skills and isolates other elements', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
// Initially 0
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(0)
// Level 20 base: +278%
stats.setBaseSkillLevel(63, 20)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(278)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(278)
// +5 all skills -> effective slvl 25 -> 50 + 24*12 = 338%
stats.setAllSkillsBonus(5)
expect(stats.getEffectiveSkillLevel(63)).toBe(25)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(338)
// +3 single skill bonus -> effective slvl 28 -> 50 + 27*12 = 374%
stats.setBonusSkillLevel(63, 3)
expect(stats.getEffectiveSkillLevel(63)).toBe(28)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(374)
// +2 class skills -> effective slvl 30 -> 50 + 29*12 = 398%
stats.setClassSkillsBonus(2)
expect(stats.getEffectiveSkillLevel(63)).toBe(30)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(398)
// Verify fire, cold, and poison masteries remain unaffected
expect(stats.getAccruedStat('passive_fire_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_cold_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_pois_mastery')).toBe(0)
})
it('verifies combat pipeline executeSUnitDmg applies passive_ltng_mastery to lightning attacks only', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, {
maxhp: 2000 * FIXED_ONE,
hitpoints: 2000 * FIXED_ONE,
})
// Attacker has Level 20 Lightning Mastery (+278%)
attackerStats.setBaseSkillLevel(63, 20)
expect(attackerStats.getAccruedStat('passive_ltng_mastery')).toBe(278)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
const attackerCtx: CombatUnitContext = {
id: 'attacker-1',
name: 'Sorc',
statList: attackerStats,
stateBus: attackerBus,
}
const defenderCtx: CombatUnitContext = {
id: 'defender-1',
name: 'Target',
statList: defenderStats,
stateBus: defenderBus,
}
// Attack 1: Lightning Damage 100
// 100 * (1 + 2.78) = 378
const resLtng = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49,
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resLtng.totalDamage).toBe(378)
expect(resLtng.elemDamage256).toBe(378 * FIXED_ONE)
// Attack 2: Fire Damage 100 (must NOT be scaled by Lightning Mastery)
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47,
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resFire.totalDamage).toBe(100)
// Attack 3: Cold Damage 100 (must NOT be scaled by Lightning Mastery)
const resCold = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 59,
attackKind: 'spell',
autoHit: true,
elemType: 'cold',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
expect(resCold.totalDamage).toBe(100)
})
})
// =========================================================================
// 4. Engine & State Bus Integrity
// =========================================================================
describe('4. Engine & State Bus Integrity', () => {
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
const count = Object.keys(BATCH1_SKILLS).length
expect(count).toBe(38)
// Check representative Batch 1 skills exist
expect(BATCH1_SKILLS[36]).toBeDefined() // Fire Bolt
expect(BATCH1_SKILLS[47]).toBeDefined() // Fire Ball
expect(BATCH1_SKILLS[54]).toBeDefined() // Teleport
expect(BATCH1_SKILLS[112]).toBeDefined() // Blessed Hammer
})
it('verifies scene query interfaces and casting mechanics for M4 skills', () => {
// Thunder Storm (57)
expect(getSkillMana(57)).toBe(19)
expect(getSkillRange(57)).toBe(340)
// Energy Shield (58)
expect(getSkillMana(58)).toBe(5)
expect(getSkillRange(58)).toBe(0)
expect(calculateSkillDamage(58, 20)).toEqual({ min: 0, max: 0 })
// Lightning Mastery (63)
expect(getSkillMana(63)).toBe(0)
expect(getSkillRange(63)).toBe(0)
expect(calculateSkillDamage(63, 20)).toEqual({ min: 0, max: 0 })
})
it('verifies canonical missile and overlay assets for M4 skills', () => {
// thunderstorm1 missile
const tsMsl = CANONICAL_113C_MISSILES['thunderstorm1']
expect(tsMsl).toBeDefined()
expect(tsMsl.id).toBe(166)
expect(tsMsl.celFile).toBe('LightningboltBig')
expect(MISSILE_METAS['thunderstorm1']).toBeDefined()
// energyshield overlay
expect(OVERLAY_METAS['energyshield']).toBeDefined()
expect(OVERLAY_METAS['energyshieldhit0']).toBeDefined()
expect(OVERLAY_METAS['light_cast_2']).toBeDefined()
})
it('verifies act-scene castSkill executes cleanly for Thunder Storm and Energy Shield', () => {
const engine = createHarnessEngine()
const player = engine.world.player
const context = {
engine,
runtime: null as any,
hudManager: null,
status: { textContent: '' } as any,
playerAnimator: null as any,
character: null as any,
}
// Cast Thunder Storm (57)
const tsOk = castSkill(57, 100, 100, context)
expect(tsOk).toBe(true)
expect(player.thunderstormTicks).toBe(800) // slvl 1
expect(player.thunderstormInterval).toBe(110)
expect(player.thunderstormNextStrike).toBe(110)
// Cast Energy Shield (58)
const esOk = castSkill(58, 100, 100, context)
expect(esOk).toBe(true)
expect(player.energyshieldTicks).toBe(3600) // slvl 1
expect(player.energyshieldPct).toBe(20)
// Check that looping overlay energyshield was spawned
const esOv = engine.overlays.find(o => o.overlayName === 'energyshield' && o.casterId === 'player')
expect(esOv).toBeDefined()
expect(esOv?.loop).toBe(true)
})
})
})