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