diablo2-web/tests/meteor-adversarial-stress.t...

522 lines
21 KiB
TypeScript

/**
* Independent Adversarial Verification Harness — Skill #056: Meteor (1.13c Parity)
*
* Authored by: teamwork_preview_challenger_meteor_1 (Empirical Challenger)
* Verification Scope:
* 1. 5-band level scaling: slvl 1, 5, 10, 20, 25, 30 against 1.13c ground truth formulas.
* Deep investigation of slvl 25 exact formula (1270-1338) vs explorer handoff arithmetic shortcut (1274-1342).
* 2. Synergy multipliers: Fire Bolt (+5%/blvl), Fire Ball (+5%/blvl), Fire Mastery multiplicative bonus,
* and Inferno (+3%/blvl) for residual ground fire.
* 3. Soft points edge cases: verifying hard points (blvl) strictly govern synergy, while soft points (+skills/charges)
* grant ZERO synergy bonuses.
* 4. Residual fire patch mechanics: HitShift 3 (Raw * 8 in 256ths), duration scaling 30 + (slvl - 1) * 15 ticks.
* 5. NextDelay verification: NextDelay = 0 in data tables and runtime simulation (overlapping patches simultaneously damage targets).
* 6. Adversarial boundaries: NaN, negative, 0, extreme levels (slvl 99), and wall clipping.
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList } from '../src/game/engine/stat-list.ts'
import { compute5BandScaling } from '../src/game/engine/calc-ast.ts'
import {
calculateMeteorDamage,
calculateMeteorSynergyMultiplier,
calculateMeteorResidualFireDamage,
calculateMeteorDetailedDamage,
calculateSkillDamage,
getSkillManaCost,
getSkillCooldownTicks,
METEOR_FIRE_SUBTILE_OFFSETS,
ISO_GROUND_ASPECT_RATIO,
parseMissilesTxt,
tickProjectiles,
type Projectile,
} from '../src/game/skills.ts'
import { skillModule } from '../src/game/skills/impl/sor/skill-056-meteor.ts'
import { MissileEngine } from '../src/game/engine/missile-engine.ts'
import type { CombatUnitContext, SUnitDmgPacket } from '../src/game/engine/combat-pipeline.ts'
describe('Adversarial Stress Test: Sorceress Skill 56 (Meteor) 1.13c Math & Mechanics', () => {
// =========================================================================
// Challenge Dimension 1: 5-Band Level Scaling & Slvl 25 Ground Truth Analysis
// =========================================================================
describe('Dimension 1: 5-Band Level Scaling & Slvl 25 Analysis', () => {
it('verifies exact 5-band scaling across benchmark levels: 1, 5, 10, 20, 30', () => {
// 1.13c Skills.txt Row 56 parameters:
// Base: EMin = 80, EMax = 100
// Band 1 (slvl 2..8, 7 levels): EMinLev1 = 23, EMaxLev1 = 25
// Band 2 (slvl 9..16, 8 levels): EMinLev2 = 39, EMaxLev2 = 41
// Band 3 (slvl 17..22, 6 levels): EMinLev3 = 79, EMaxLev3 = 81
// Band 4 (slvl 23..28, 6 levels): EMinLev4 = 81, EMaxLev4 = 83
// Band 5 (slvl 29+, beyond): EMinLev5 = 83, EMaxLev5 = 85
// slvl 1: 80 - 100
const d1 = calculateMeteorDamage(1)
expect(d1).toEqual({ min: 80, max: 100 })
// slvl 5: 80 + 4*23 = 172, 100 + 4*25 = 200
const d5 = calculateMeteorDamage(5)
expect(d5).toEqual({ min: 172, max: 200 })
// slvl 10: 80 + 7*23 + 2*39 = 319, 100 + 7*25 + 2*41 = 357
const d10 = calculateMeteorDamage(10)
expect(d10).toEqual({ min: 319, max: 357 })
// slvl 20: 80 + 7*23 + 8*39 + 4*79 = 869, 100 + 7*25 + 8*41 + 4*81 = 927
const d20 = calculateMeteorDamage(20)
expect(d20).toEqual({ min: 869, max: 927 })
// slvl 30: 869 + 2*79 (slvl 21-22) + 6*81 (slvl 23-28) + 2*83 (slvl 29-30) = 1679
// max: 927 + 2*81 + 6*83 + 2*85 = 1757
const d30 = calculateMeteorDamage(30)
expect(d30).toEqual({ min: 1679, max: 1757 })
})
it('proves slvl 25 mathematical ground truth: 1270-1338 vs explorer report 1274-1342', () => {
// Deep Mathematical Proof:
// Band 3 comprises slvl 17..22 (total 6 levels: 17, 18, 19, 20, 21, 22) with rate 79/81.
// At slvl 20, only 4 levels of Band 3 have elapsed (17..20).
// Slvl 21 and 22 STILL belong to Band 3!
// Therefore:
// slvl 22 = slvl 20 + 2 * 79 = 869 + 158 = 1027 min (max = 927 + 2*81 = 1089)
// Band 4 begins at slvl 23!
// At slvl 25, exactly 3 levels of Band 4 have elapsed (slvl 23, 24, 25) with rate 81/83.
// slvl 25 = 1027 + 3 * 81 = 1027 + 243 = 1270 min
// slvl 25 max = 1089 + 3 * 83 = 1089 + 249 = 1338 max
//
// Origin of Explorer's 1274-1342:
// The explorer calculated: slvl 20 (869) + 5 * 81 = 869 + 405 = 1274
// and max: 927 + 5 * 83 = 927 + 415 = 1342.
// This erroneously treated slvl 21 and 22 as Band 4 (+81) rather than Band 3 (+79),
// introducing an off-by-4 error (+2 per level for 2 levels = +4).
// If 1274 were correct, slvl 30 would be 1274 + 3*81 + 2*83 = 1274 + 243 + 166 = 1683 (NOT 1679).
// Only 1270 correctly reconciles with the undisputed slvl 30 ground truth of 1679!
const d25 = calculateMeteorDamage(25)
expect(d25.min).toBe(1270)
expect(d25.max).toBe(1338)
// Verification via core compute5BandScaling function
const c5bMin = compute5BandScaling(25, 80, 23, 39, 79, 81, 83)
const c5bMax = compute5BandScaling(25, 100, 25, 41, 81, 83, 85)
expect(c5bMin).toBe(1270)
expect(c5bMax).toBe(1338)
// Assert that 1270 + remainder of Band 4 (3 levels) + 2 levels of Band 5 = slvl 30
expect(1270 + 3 * 81 + 2 * 83).toBe(1679)
expect(1338 + 3 * 83 + 2 * 85).toBe(1757)
})
it('exhaustively tests 5-band monotonicity across slvl 1 to 99', () => {
let prevMin = 0
let prevMax = 0
for (let lvl = 1; lvl <= 99; lvl++) {
const dmg = calculateMeteorDamage(lvl)
expect(dmg.min).toBeGreaterThan(prevMin)
expect(dmg.max).toBeGreaterThan(prevMax)
expect(dmg.max).toBeGreaterThan(dmg.min)
prevMin = dmg.min
prevMax = dmg.max
}
})
})
// =========================================================================
// Challenge Dimension 2: Synergy Multipliers & Multiplicative Fire Mastery
// =========================================================================
describe('Dimension 2: Synergy Multipliers & Mastery Scaling', () => {
it('verifies +5% per hard point in Fire Bolt (36) and Fire Ball (47)', () => {
// 0 synergies: 1.0x
expect(calculateMeteorSynergyMultiplier({})).toBe(1.0)
// Fire Bolt 1 pt: 1.05x
expect(calculateMeteorSynergyMultiplier({ fireBolt: 1 })).toBe(1.05)
// Fire Bolt 20 pts: 2.00x (+100%)
expect(calculateMeteorSynergyMultiplier({ fireBolt: 20 })).toBe(2.00)
// Fire Ball 1 pt: 1.05x
expect(calculateMeteorSynergyMultiplier({ fireBall: 1 })).toBe(1.05)
// Fire Ball 20 pts: 2.00x (+100%)
expect(calculateMeteorSynergyMultiplier({ fireBall: 20 })).toBe(2.00)
// Fire Bolt 20 + Fire Ball 20: 3.00x (+200%)
expect(calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20 })).toBe(3.00)
// Damage checks at slvl 20 (base 869-927)
const d20Bolt20 = calculateMeteorDamage(20, { fireBolt: 20 })
expect(d20Bolt20.min).toBe(Math.floor(869 * 2.0)) // 1738
expect(d20Bolt20.max).toBe(Math.floor(927 * 2.0)) // 1854
const d20Both20 = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20 })
expect(d20Both20.min).toBe(Math.floor(869 * 3.0)) // 2607
expect(d20Both20.max).toBe(Math.floor(927 * 3.0)) // 2781
})
it('verifies Fire Mastery (61) multiplicative scaling with synergies', () => {
// In 1.13c, Fire Mastery: 30% base (lvl 1) + 7%/lvl
// Level 1: +30% -> (1 + 0.30) = 1.30
// Level 20: 30 + 19 * 7 = 163% -> (1 + 1.63) = 2.63
const synMastery1 = calculateMeteorSynergyMultiplier({ fireMastery: 1 })
expect(synMastery1).toBeCloseTo(1.30, 4)
const synMastery20 = calculateMeteorSynergyMultiplier({ fireMastery: 20 })
expect(synMastery20).toBeCloseTo(2.63, 4)
// Synergies + Mastery are multiplicative:
// (1 + (20+20)*0.05) * (1 + 1.63) = 3.0 * 2.63 = 7.89
const synFull = calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20, fireMastery: 20 })
expect(synFull).toBeCloseTo(7.89, 4)
const d20Full = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20, fireMastery: 20 })
expect(d20Full.min).toBe(Math.floor(869 * 7.89)) // 6856
expect(d20Full.max).toBe(Math.floor(927 * 7.89)) // 7314
})
it('verifies residual fire synergy: Inferno (41) +3%/lvl and Fire Mastery', () => {
// Base residual fire at slvl 1: 15 - 25
const resBase = calculateMeteorResidualFireDamage(1)
expect(resBase.min).toBe(15)
expect(resBase.max).toBe(25)
// Inferno 1 pt: +3% -> 1.03x
const resInf1 = calculateMeteorResidualFireDamage(1, { inferno: 1 })
expect(resInf1.min).toBe(Math.floor(15 * 1.03)) // 15
expect(resInf1.max).toBe(Math.floor(25 * 1.03)) // 25
// Inferno 20 pts: +60% -> 1.60x
const resInf20 = calculateMeteorResidualFireDamage(1, { inferno: 20 })
expect(resInf20.min).toBe(Math.floor(15 * 1.60)) // 24
expect(resInf20.max).toBe(Math.floor(25 * 1.60)) // 40
// Inferno 20 pts + Fire Mastery 20 (+163%): 1.60 * 2.63 = 4.208
const resBoth20 = calculateMeteorResidualFireDamage(1, { inferno: 20, fireMastery: 20 })
expect(resBoth20.min).toBe(Math.floor(15 * 1.60 * 2.63)) // 63
expect(resBoth20.max).toBe(Math.floor(25 * 1.60 * 2.63)) // 105
})
})
// =========================================================================
// Challenge Dimension 3: Soft Points vs Hard Points (Marrowwalk Synergy Rule)
// =========================================================================
describe('Dimension 3: Soft Points vs Hard Points Edge Cases', () => {
it('verifies that item soft points (+skills, +fireskills, charges) provide ZERO synergy bonus', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(56)!
// Case A: 20 Base Points in Meteor, 0 Base in Fire Bolt, but +20 allskills & +33 charged skill
const statsSoft = new UnitStatList()
statsSoft.setBaseSkillLevel(56, 20)
statsSoft.addStat('item_allskills', 20)
statsSoft.addStat('item_sorceress_skills', 10)
statsSoft.setChargedSkillLevel(36, 33) // Fire Bolt charges (Marrowwalk bug test)
const evalSoft = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 50, // 20 base + 30 soft
blvl: 20,
statList: statsSoft,
})
// Synergy bonus percentage MUST BE 0!
expect(evalSoft.synergyBonusPct).toBe(0)
// Case B: 20 Base Points in Fire Bolt (hard points) without soft points
const statsHard = new UnitStatList()
statsHard.setBaseSkillLevel(56, 20)
statsHard.setBaseSkillLevel(36, 20) // 20 hard points in Fire Bolt
const evalHard = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsHard,
})
// Synergy bonus percentage MUST BE exactly 100% (+5% * 20)
expect(evalHard.synergyBonusPct).toBe(100)
// Case C: 20 Base Points in Fire Bolt PLUS +20 soft points in allskills
const statsMixed = new UnitStatList()
statsMixed.setBaseSkillLevel(56, 20)
statsMixed.setBaseSkillLevel(36, 20) // 20 hard points
statsMixed.addStat('item_allskills', 20) // +20 soft points
const evalMixed = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 40,
blvl: 20,
statList: statsMixed,
})
// Synergy bonus percentage MUST STILL BE exactly 100%, NOT 200%!
expect(evalMixed.synergyBonusPct).toBe(100)
})
})
// =========================================================================
// Challenge Dimension 4: Residual Fire Patch Math & Duration Scaling
// =========================================================================
describe('Dimension 4: Residual Fire Patch Math & Duration Scaling', () => {
it('verifies 5-band residual fire damage progression: [4, 5, 6, 6, 6]', () => {
// Base: 15-25
// slvl 1: 15 - 25
const r1 = calculateMeteorResidualFireDamage(1)
expect(r1.min).toBe(15)
expect(r1.max).toBe(25)
// slvl 5: 15 + 4*4 = 31, 25 + 4*4 = 41
const r5 = calculateMeteorResidualFireDamage(5)
expect(r5.min).toBe(31)
expect(r5.max).toBe(41)
// slvl 10: 15 + 7*4 + 2*5 = 53, 25 + 7*4 + 2*5 = 63
const r10 = calculateMeteorResidualFireDamage(10)
expect(r10.min).toBe(53)
expect(r10.max).toBe(63)
// slvl 20: 15 + 7*4 + 8*5 + 4*6 = 107, 25 + 7*4 + 8*5 + 4*6 = 117
const r20 = calculateMeteorResidualFireDamage(20)
expect(r20.min).toBe(107)
expect(r20.max).toBe(117)
// slvl 25: 107 + 2*6 + 3*6 = 137, 117 + 2*6 + 3*6 = 147
const r25 = calculateMeteorResidualFireDamage(25)
expect(r25.min).toBe(137)
expect(r25.max).toBe(147)
// slvl 30: 107 + 2*6 + 6*6 + 2*6 = 167, 117 + 2*6 + 6*6 + 2*6 = 177
const r30 = calculateMeteorResidualFireDamage(30)
expect(r30.min).toBe(167)
expect(r30.max).toBe(177)
})
it('verifies HitShift 3 precision (Raw * 8 in 256ths) and per-second conversion (Raw * 200 / 256)', () => {
// 1.13c HitShift = 3: Damage256 = Raw * 8
for (const slvl of [1, 5, 10, 20, 25, 30]) {
const res = calculateMeteorResidualFireDamage(slvl)
const tickMin256 = res.min * 8
const tickMax256 = res.max * 8
expect(tickMin256).toBe(res.min << 3)
expect(tickMax256).toBe(res.max << 3)
// 25 ticks/sec -> perSec = floor(Damage256 * 25 / 256) = floor(Raw * 200 / 256)
expect(res.perSecMin).toBe(Math.floor((res.min * 200) / 256))
expect(res.perSecMax).toBe(Math.floor((res.max * 200) / 256))
}
})
it('verifies exact residual fire duration scaling: 30 + (slvl - 1) * 15 ticks', () => {
const expectedDurations = [
{ slvl: 1, ticks: 30, sec: 1.20 },
{ slvl: 5, ticks: 90, sec: 3.60 },
{ slvl: 10, ticks: 165, sec: 6.60 },
{ slvl: 20, ticks: 315, sec: 12.60 },
{ slvl: 25, ticks: 390, sec: 15.60 },
{ slvl: 30, ticks: 465, sec: 18.60 },
]
for (const exp of expectedDurations) {
const dur = 30 + (exp.slvl - 1) * 15
expect(dur).toBe(exp.ticks)
expect(dur / 25).toBeCloseTo(exp.sec, 2)
}
})
})
// =========================================================================
// Challenge Dimension 5: NextDelay = 0 & Overlapping Fire Patch Multi-Hit
// =========================================================================
describe('Dimension 5: NextDelay = 0 & Overlapping Fire Patches', () => {
it('verifies from authentic Missiles.txt that all Meteor missiles have NextDelay = 0', () => {
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
const content = fs.readFileSync(missilesTxtPath, 'latin1')
const parsed = parseMissilesTxt(content)
const meteorMissiles = ['meteorcenter', 'meteor', 'meteortail', 'meteorexplode', 'meteorfire']
for (const name of meteorMissiles) {
const m = parsed.get(name)
expect(m, `Missile ${name} not found in Missiles.txt`).toBeDefined()
// In 1.13c Missiles.txt, NextDelay and NextHit are either 0 or undefined for Meteor suite
const rec = m as any
expect(rec.nextDelay ?? 0).toBe(0)
expect(rec.nextHit ?? 0).toBe(0)
}
})
it('simulates multiple overlapping meteorfire patches hitting the same target simultaneously without NextDelay lockout', () => {
// Create 3 overlapping meteorfire projectiles at the exact same location
const p1: Projectile = {
skillId: '56',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: 10,
ttl: 30,
fromPlayer: true,
missileType: 'meteorfire',
pierce: true,
}
const p2: Projectile = {
skillId: '56',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: 15,
ttl: 30,
fromPlayer: true,
missileType: 'meteorfire',
pierce: true,
}
const p3: Projectile = {
skillId: '56',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: 20,
ttl: 30,
fromPlayer: true,
missileType: 'meteorfire',
pierce: true,
}
const monster = [{ index: 42, x: 200, y: 200, radius: 20, alive: true }]
const outcome = tickProjectiles([p1, p2, p3], monster, { overlap: () => 0 })
// Because NextDelay = 0, all 3 overlapping patches MUST deal damage in the exact same tick!
expect(outcome.hits.length).toBe(3)
const dmgDealt = outcome.hits.map(h => h.damage).sort((a, b) => a - b)
expect(dmgDealt).toEqual([10, 15, 20])
expect(outcome.alive.length).toBe(3)
})
it('simulates periodic burn over 20 ticks: verifies burn cadence every 5 ticks', () => {
let p: Projectile = {
skillId: '56',
x: 150,
y: 150,
vx: 0,
vy: 0,
damage: 25,
ttl: 30,
fromPlayer: true,
missileType: 'meteorfire',
pierce: true,
}
const monster = [{ index: 99, x: 150, y: 150, radius: 20, alive: true }]
let totalHits = 0
for (let t = 1; t <= 20; t++) {
const out = tickProjectiles([p], monster, { overlap: () => 0 })
p = out.alive[0]!
totalHits += out.hits.length
}
// Cadence: hits on tick 1, 5, 10, 15, 20 = 5 total hits over 20 ticks
expect(totalHits).toBe(5)
expect(p.ttl).toBe(10) // 30 - 20 = 10 ticks remaining
})
})
// =========================================================================
// Challenge Dimension 6: Adversarial Boundary & Resilience Inputs
// =========================================================================
describe('Dimension 6: Adversarial Boundary & Input Fuzzing', () => {
it('handles non-finite, zero, and negative skill levels gracefully (clamps to 1)', () => {
const dZero = calculateMeteorDamage(0)
const dNeg = calculateMeteorDamage(-10)
const dNaN = calculateMeteorDamage(NaN)
const dInf = calculateMeteorDamage(Infinity)
expect(dZero).toEqual({ min: 80, max: 100 })
expect(dNeg).toEqual({ min: 80, max: 100 })
expect(dNaN).toEqual({ min: 80, max: 100 })
const mZero = getSkillManaCost(56, 0)
const mNeg = getSkillManaCost(56, -5)
const mNaN = getSkillManaCost(56, NaN)
expect(mZero).toBe(17)
expect(mNeg).toBe(17)
expect(mNaN).toBe(17)
})
it('handles negative or invalid synergy values safely', () => {
const synNeg = calculateMeteorSynergyMultiplier({ fireBolt: -5, fireBall: -10 })
// Even with negative points, multiplier should not drop below 0 or break calculations
expect(Number.isFinite(synNeg)).toBe(true)
const dNegSyn = calculateMeteorDamage(20, -1)
expect(dNegSyn).toEqual({ min: 869, max: 927 }) // invalid number reverts to 1.0x
})
it('verifies generic calculateSkillDamage router accepts all valid representations', () => {
const resNum = calculateSkillDamage(56, 10)
const resStr = calculateSkillDamage('56', 10)
const resName = calculateSkillDamage('meteor', 10)
expect(resNum).toEqual({ min: 319, max: 357 })
expect(resStr).toEqual({ min: 319, max: 357 })
expect(resName).toEqual({ min: 319, max: 357 })
})
it('verifies 18-offset ground fire geometry matches D2Game.dll 0x6FD1C918 / 0x6FD1C8D0', () => {
expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18)
const expected = [
{ x: 2, y: -2 },
{ x: -2, y: -2 },
{ x: 0, y: 2 },
{ x: 0, y: 5 },
{ x: -3, y: 3 },
{ x: 0, y: 3 },
{ x: 3, y: 3 },
{ x: -1, y: 2 },
{ x: 1, y: 1 },
{ x: -1, y: -1 },
{ x: 2, y: -1 },
{ x: -4, y: -2 },
{ x: -3, y: -2 },
{ x: -1, y: -3 },
{ x: 0, y: -4 },
{ x: 1, y: -3 },
{ x: 3, y: -3 },
{ x: 4, y: -2 },
]
expect(METEOR_FIRE_SUBTILE_OFFSETS).toEqual(expected)
})
it('verifies wall collision suppresses out-of-bounds fire patch generation', () => {
const center: Projectile = {
skillId: '56',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 200,
ttl: 0, // detonates immediately
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
}
// Block everywhere where x > 100 (half the circle blocked by wall)
const outcome = tickProjectiles([center], [], {
overlap: (x, y) => (x > 100 ? 1 : 0),
})
const flames = outcome.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(flames.length).toBeLessThan(18)
expect(flames.length).toBeGreaterThan(0)
for (const flame of flames) {
expect(flame.x).toBeLessThanOrEqual(100)
}
})
})
})