/** * 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/server/skills/skills.ts' import { executeSUnitDmg, type CombatUnitContext, } from '../src/server/engine/combat-pipeline.ts' import { getSharedDataRegistry } from '../src/baker/mpq-archive-source.ts' import { UnitStatList, FIXED_ONE } from '../src/server/engine/stat-list.ts' import { StateBus } from '../src/server/engine/state-bus.ts' import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/server/engine/engine.ts' import { getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts' import { castSkill } from '../src/server/scene/skill-caster.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts' import { OVERLAY_METAS } from '../src/client/render/overlays-meta.ts' import { MISSILE_METAS } from '../src/client/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) }) }) })