diablo2-web/tests/challenger-m4-thunder-storm...

444 lines
19 KiB
TypeScript

/**
* 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)
})
})
})