diablo2-web/tests/skills/dru/adv-dru-adversarial-challen...

578 lines
22 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Druid Milestone M25 Adversarial Challenger Suite
*
* Empirical verification and adversarial stress-testing across 6 core mechanical dimensions:
* 1. Shapeshifted Casting Restrictions (Armageddon/Hurricane shift aura attachment; spell/summon fail-fast)
* 2. Shapeshifting Form Exclusivity (Werewolf <-> Werebear mutual eviction; form-exclusive attack execution)
* 3. Cyclone Armor Absorption Invariant (Fire/Cold/Ltng absorption; Physical & Poison complete bypass)
* 4. Tornado NextHitDelay (25-frame NextDelay target immunity window)
* 5. Feral Rage & Maul Orbs (Formula scaling, charge progression, runtime state application)
* 6. Rabies Contagion Mechanics (Poison damage, contagion spread simulation, runtime state attachment)
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
import { executeSkill113c, evaluateSkill113c } from '../../../src/game/skills/registry.ts'
import {
canCastDruidSkill,
calculateFeralRageStats,
calculateMaulStats,
calculateRabiesStats,
advanceChargeOrb,
simulateRabiesContagion,
} from '../../../src/game/skills/druid-shapeshift.ts'
import {
simulateCycloneArmorAbsorption,
} from '../../../src/game/skills/druid-elemental.ts'
function createTestUnit(
id: string,
name: string,
unitType: 'player' | 'monster',
maxHp = 1000,
): CombatUnitContext {
const statList = new UnitStatList()
statList.addStat('maxhp', maxHp)
statList.setHp256(maxHp << 8)
const stateBus = new StateBus(statList)
return {
id,
name,
unitType,
statList,
stateBus,
}
}
describe('Adversarial Stress Testing — Cohort 6: Druid Elemental & Shapeshifting Skills', () => {
// =========================================================================
// Dimension 1: Shapeshifted Casting Restrictions
// =========================================================================
describe('Dimension 1: Shapeshifted Casting Restrictions', () => {
it('1.1 canCastDruidSkill predicate correctly permits Armageddon (249) and Hurricane (250) in all forms', () => {
expect(canCastDruidSkill('human', 249)).toBe(true)
expect(canCastDruidSkill('wolf', 249)).toBe(true)
expect(canCastDruidSkill('bear', 249)).toBe(true)
expect(canCastDruidSkill('human', 250)).toBe(true)
expect(canCastDruidSkill('wolf', 250)).toBe(true)
expect(canCastDruidSkill('bear', 250)).toBe(true)
})
it('1.2 canCastDruidSkill predicate rejects elemental spells and summons in wolf or bear form', () => {
const humanOnlySpells = [221, 222, 225, 226, 227, 229, 230, 231, 234, 235, 236, 237, 240, 241, 244, 245, 246, 247]
for (const id of humanOnlySpells) {
expect(canCastDruidSkill('human', id)).toBe(true)
expect(canCastDruidSkill('wolf', id)).toBe(false)
expect(canCastDruidSkill('bear', id)).toBe(false)
}
})
it('1.3 [FAILING / BUG REPRO] executeSkill113c runtime attaches armageddon and hurricane aura states to caster', async () => {
const reg = await getSharedDataRegistry()
const auraScanner = new AuraScanner(reg)
const missileEngine = new MissileEngine(reg)
// In Wolf form, cast Armageddon (249)
const casterWolf = createTestUnit('dru_w', 'DruidWolf', 'player')
casterWolf.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
const skill249 = reg.getSkillById(249)!
const eval249 = evaluateSkill113c({ registry: reg, skill: skill249, slvl: 20, blvl: 20, statList: casterWolf.statList, stateBus: casterWolf.stateBus })
const out249 = executeSkill113c({
registry: reg,
skill: skill249,
evalResult: eval249,
caster: casterWolf,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
expect(out249.executed).toBe(true)
// D2MOO SkillDruid.cpp:957 (SKILLS_SrvDo124_Armageddon_Hurricane) toggles state on caster
expect(casterWolf.stateBus.hasState('armageddon')).toBe(true)
// In Bear form, cast Hurricane (250)
const casterBear = createTestUnit('dru_b', 'DruidBear', 'player')
casterBear.stateBus.applyState({ stateNameOrId: 'bear', slvl: 10, stats: {} })
const skill250 = reg.getSkillById(250)!
const eval250 = evaluateSkill113c({ registry: reg, skill: skill250, slvl: 20, blvl: 20, statList: casterBear.statList, stateBus: casterBear.stateBus })
const out250 = executeSkill113c({
registry: reg,
skill: skill250,
evalResult: eval250,
caster: casterBear,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
expect(out250.executed).toBe(true)
expect(casterBear.stateBus.hasState('hurricane')).toBe(true)
})
it('1.4 [FAILING / BUG REPRO] executeSkill113c runtime fails fast when casting prohibited elemental spells or summons while shapeshifted', async () => {
const reg = await getSharedDataRegistry()
const missileEngine = new MissileEngine(reg)
const summonManager = new SummonManager(reg)
const casterWolf = createTestUnit('dru_w', 'DruidWolf', 'player')
casterWolf.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
// Firestorm (225) attempted in wolf form must fail fast (executed = false)
const skill225 = reg.getSkillById(225)!
const eval225 = evaluateSkill113c({ registry: reg, skill: skill225, slvl: 10, blvl: 10, statList: casterWolf.statList, stateBus: casterWolf.stateBus })
const out225 = executeSkill113c({
registry: reg,
skill: skill225,
evalResult: eval225,
caster: casterWolf,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner: null as any,
summonManager,
currentTick: 0,
targetX: 100,
targetY: 100,
})
expect(out225.executed).toBe(false)
// Raven (221) attempted in wolf form must fail fast (executed = false)
const skill221 = reg.getSkillById(221)!
const eval221 = evaluateSkill113c({ registry: reg, skill: skill221, slvl: 10, blvl: 10, statList: casterWolf.statList, stateBus: casterWolf.stateBus })
const out221 = executeSkill113c({
registry: reg,
skill: skill221,
evalResult: eval221,
caster: casterWolf,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner: null as any,
summonManager,
currentTick: 0,
targetX: 100,
targetY: 100,
})
expect(out221.executed).toBe(false)
})
})
// =========================================================================
// Dimension 2: Shapeshifting Form Exclusivity
// =========================================================================
describe('Dimension 2: Shapeshifting Form Exclusivity', () => {
it('2.1 Casting Werebear while Werewolf is active evicts Werewolf state and applies Werebear', async () => {
const reg = await getSharedDataRegistry()
const caster = createTestUnit('dru_1', 'DruidCaster', 'player')
// 1. Cast Werewolf
const skill223 = reg.getSkillById(223)!
const eval223 = evaluateSkill113c({ registry: reg, skill: skill223, slvl: 10, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
executeSkill113c({
registry: reg,
skill: skill223,
evalResult: eval223,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine: null as any,
auraScanner: null as any,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
expect(caster.stateBus.hasState('wolf')).toBe(true)
expect(caster.stateBus.hasState('bear')).toBe(false)
// 2. Cast Werebear -> wolf must be evicted
const skill228 = reg.getSkillById(228)!
const eval228 = evaluateSkill113c({ registry: reg, skill: skill228, slvl: 10, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
executeSkill113c({
registry: reg,
skill: skill228,
evalResult: eval228,
caster,
targets: [],
targetPositions: new Map(),
corpses: [],
missileEngine: null as any,
auraScanner: null as any,
summonManager: null as any,
currentTick: 10,
targetX: 0,
targetY: 0,
})
expect(caster.stateBus.hasState('wolf')).toBe(false)
expect(caster.stateBus.hasState('bear')).toBe(true)
})
it('2.2 [FAILING / BUG REPRO] executeSkill113c rejects Werewolf attacks in Bear or Human form, and Werebear attacks in Wolf or Human form', async () => {
const reg = await getSharedDataRegistry()
const missileEngine = new MissileEngine(reg)
const target = createTestUnit('m1', 'Fallen', 'monster')
// Human casting Feral Rage (232) must be rejected
const casterHuman = createTestUnit('dru_h', 'DruidHuman', 'player')
const skill232 = reg.getSkillById(232)!
const eval232 = evaluateSkill113c({ registry: reg, skill: skill232, slvl: 10, blvl: 10, statList: casterHuman.statList, stateBus: casterHuman.stateBus })
const out232 = executeSkill113c({
registry: reg,
skill: skill232,
evalResult: eval232,
caster: casterHuman,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner: null as any,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
expect(out232.executed).toBe(false)
// Wolf casting Maul (233) must be rejected
const casterWolf = createTestUnit('dru_w', 'DruidWolf', 'player')
casterWolf.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
const skill233 = reg.getSkillById(233)!
const eval233 = evaluateSkill113c({ registry: reg, skill: skill233, slvl: 10, blvl: 10, statList: casterWolf.statList, stateBus: casterWolf.stateBus })
const out233 = executeSkill113c({
registry: reg,
skill: skill233,
evalResult: eval233,
caster: casterWolf,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine,
auraScanner: null as any,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
expect(out233.executed).toBe(false)
})
})
// =========================================================================
// Dimension 3: Cyclone Armor Absorption Invariant
// =========================================================================
describe('Dimension 3: Cyclone Armor Absorption Invariant', () => {
it('3.1 Pure simulator: absorbs Fire, Cold, Lightning, and allows Physical and Poison to bypass', () => {
const cap = 300
const fRes = simulateCycloneArmorAbsorption(cap, 100, 'fire')
expect(fRes.absorbed).toBe(100)
expect(fRes.remainingArmor).toBe(200)
const cRes = simulateCycloneArmorAbsorption(200, 100, 'cold')
expect(cRes.absorbed).toBe(100)
expect(cRes.remainingArmor).toBe(100)
const lRes = simulateCycloneArmorAbsorption(100, 100, 'lightning')
expect(lRes.absorbed).toBe(100)
expect(lRes.remainingArmor).toBe(0)
expect(lRes.didDissipate).toBe(true)
const pRes = simulateCycloneArmorAbsorption(300, 100, 'physical')
expect(pRes.absorbed).toBe(0)
expect(pRes.penetrated).toBe(100)
const psnRes = simulateCycloneArmorAbsorption(300, 100, 'poison')
expect(psnRes.absorbed).toBe(0)
expect(psnRes.penetrated).toBe(100)
})
it('3.2 [FAILING / BUG REPRO] executeSUnitDmg: Poison damage completely bypasses Cyclone Armor and deals direct damage to defender HP', () => {
const attacker = createTestUnit('attacker_1', 'PoisonCaster', 'monster')
const defender = createTestUnit('defender_1', 'DruidDefender', 'player', 1000)
defender.stateBus.applyState({
stateNameOrId: 'cyclonearmor',
slvl: 10,
stats: { magicarmor: 148 },
})
// 100 Poison damage incoming
const poisHit = executeSUnitDmg(attacker, defender, {
skillId: 92, // Poison Nova
elemType: 'pois',
elemMin256: 100 << 8,
elemMax256: 100 << 8,
isPoison: true,
autoHit: true,
})
// In authentic 1.13c, Poison damage bypasses Cyclone Armor:
// - Cyclone Armor magicarmor remains untouched at 148
// - Defender HP takes full 100 damage (1000 -> 900)
expect(defender.stateBus.getState('cyclonearmor')?.stats['magicarmor']).toBe(148)
expect(defender.statList.getHp256() >> 8).toBe(900)
expect(poisHit.totalDamage).toBe(100)
})
it('3.3 executeSUnitDmg: Physical damage completely bypasses Cyclone Armor', () => {
const attacker = createTestUnit('attacker_1', 'MeleeMonster', 'monster')
const defender = createTestUnit('defender_1', 'DruidDefender', 'player', 1000)
defender.stateBus.applyState({
stateNameOrId: 'cyclonearmor',
slvl: 10,
stats: { magicarmor: 148 },
})
// 100 Physical damage incoming
const physHit = executeSUnitDmg(attacker, defender, {
skillId: 0,
flatPhysMin256: 100 << 8,
flatPhysMax256: 100 << 8,
autoHit: true,
})
// Physical damage bypasses Cyclone Armor:
expect(defender.stateBus.getState('cyclonearmor')?.stats['magicarmor']).toBe(148)
expect(defender.statList.getHp256() >> 8).toBe(900)
expect(physHit.totalDamage).toBe(100)
})
})
// =========================================================================
// Dimension 4: Tornado NextHitDelay
// =========================================================================
describe('Dimension 4: Tornado NextHitDelay', () => {
it('4.1 Tornado missile has NextHit = 1 and NextDelay = 25 in DataRegistry', async () => {
const reg = await getSharedDataRegistry()
const mRec = reg.getMissileByName('tornado')
expect(mRec).toBeDefined()
expect(mRec!.nextHit).toBe(true)
expect(mRec!.nextDelay).toBe(25)
})
it('4.2 MissileEngine enforces 25-frame NextDelay window preventing multi-hits', async () => {
const reg = await getSharedDataRegistry()
const engine = new MissileEngine(reg)
const caster = createTestUnit('dru_1', 'DruidCaster', 'player')
const target = createTestUnit('target_1', 'MonsterTarget', 'monster', 1000)
const targetPositions = new Map([['target_1', { x: 100, y: 100 }]])
// First tornado hits at tick 0
engine.spawnMissile({
missileNameOrId: 'tornado',
sourceSkillId: 245,
slvl: 1,
owner: caster,
startX: 100,
startY: 100,
targetX: 200,
targetY: 100,
dmgPacket: { skillId: 245, flatPhysMin256: 100 << 8, flatPhysMax256: 100 << 8 },
})
const hit1 = engine.tick(0, [target], targetPositions)
expect(hit1.hits.length).toBe(1)
expect(engine.isTargetInNextDelay('target_1', 0)).toBe(true)
expect(engine.isTargetInNextDelay('target_1', 24)).toBe(true)
expect(engine.isTargetInNextDelay('target_1', 25)).toBe(false)
// Second tornado inside immunity window (tick 10) must be blocked
engine.spawnMissile({
missileNameOrId: 'tornado',
sourceSkillId: 245,
slvl: 1,
owner: caster,
startX: 100,
startY: 100,
targetX: 200,
targetY: 100,
dmgPacket: { skillId: 245, flatPhysMin256: 100 << 8, flatPhysMax256: 100 << 8 },
})
const hit2 = engine.tick(10, [target], targetPositions)
expect(hit2.hits.length).toBe(0)
// Third tornado outside window (tick 26) must hit
engine.spawnMissile({
missileNameOrId: 'tornado',
sourceSkillId: 245,
slvl: 1,
owner: caster,
startX: 100,
startY: 100,
targetX: 200,
targetY: 100,
dmgPacket: { skillId: 245, flatPhysMin256: 100 << 8, flatPhysMax256: 100 << 8 },
})
const hit3 = engine.tick(26, [target], targetPositions)
expect(hit3.hits.length).toBe(1)
})
})
// =========================================================================
// Dimension 5: Feral Rage & Maul Orbs
// =========================================================================
describe('Dimension 5: Feral Rage & Maul Orbs', () => {
it('5.1 Feral Rage formulas: 3 stages, 4*slvl% life leech, dm(10, 70, slvl) speed, 20.0s decay timer', () => {
const fr1 = calculateFeralRageStats(1)
expect(fr1.maxCharges).toBe(3)
expect(fr1.lifeLeechPct).toBe(4)
expect(fr1.velocityBonusPct).toBe(19)
expect(fr1.durationFrames).toBe(500)
expect(fr1.durationSeconds).toBe(20.0)
const fr10 = calculateFeralRageStats(10)
expect(fr10.lifeLeechPct).toBe(40)
expect(fr10.velocityBonusPct).toBe(51)
const fr20 = calculateFeralRageStats(20)
expect(fr20.lifeLeechPct).toBe(80)
expect(fr20.velocityBonusPct).toBe(60)
// Charge progression caps at 3
let charges = 0
charges = advanceChargeOrb(charges, 3)
expect(charges).toBe(1)
charges = advanceChargeOrb(charges, 3)
expect(charges).toBe(2)
charges = advanceChargeOrb(charges, 3)
expect(charges).toBe(3)
charges = advanceChargeOrb(charges, 3)
expect(charges).toBe(3)
})
it('5.2 Maul formulas: 5 stages, slvl*20% damage/charge (slvl*100% max), dm(10, 100, slvl) stun, 20.0s decay timer', () => {
const m1 = calculateMaulStats(1)
expect(m1.maxCharges).toBe(5)
expect(m1.damageBonusPerChargePct).toBe(20)
expect(m1.maxDamageBonusPct).toBe(100)
expect(m1.stunDurationFrames).toBe(24)
expect(m1.durationFrames).toBe(500)
expect(m1.durationSeconds).toBe(20.0)
const m10 = calculateMaulStats(10)
expect(m10.damageBonusPerChargePct).toBe(200)
expect(m10.maxDamageBonusPct).toBe(1000)
expect(m10.stunDurationFrames).toBe(71)
const m20 = calculateMaulStats(20)
expect(m20.damageBonusPerChargePct).toBe(400)
expect(m20.maxDamageBonusPct).toBe(2000)
expect(m20.stunDurationFrames).toBe(86)
})
it('5.3 [FAILING / BUG REPRO] executeSkill113c runtime attaches feralrage and maul states to caster on hit', async () => {
const reg = await getSharedDataRegistry()
const caster = createTestUnit('dru_w', 'DruidWolf', 'player')
caster.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
const target = createTestUnit('m1', 'TargetMonster', 'monster', 1000)
const skill232 = reg.getSkillById(232)!
const eval232 = evaluateSkill113c({ registry: reg, skill: skill232, slvl: 10, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
const out232 = executeSkill113c({
registry: reg,
skill: skill232,
evalResult: eval232,
caster,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine: null as any,
auraScanner: null as any,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
// On successful hit, Feral Rage attaches feralrage charge state
expect(out232.statesApplied).toContain('feralrage')
expect(caster.stateBus.hasState('feralrage')).toBe(true)
})
})
// =========================================================================
// Dimension 6: Rabies Contagion Mechanics
// =========================================================================
describe('Dimension 6: Rabies Contagion Mechanics', () => {
it('6.1 simulateRabiesContagion spreads infection to adjacent targets within 128px radius', () => {
const infected = [{ id: 'target_0', x: 100, y: 100 }]
const healthy = [
{ id: 'target_close_1', x: 120, y: 105 },
{ id: 'target_close_2', x: 80, y: 95 },
{ id: 'target_far', x: 400, y: 400 },
]
const newlyInfected = simulateRabiesContagion(infected, healthy, 128)
expect(newlyInfected).toContain('target_close_1')
expect(newlyInfected).toContain('target_close_2')
expect(newlyInfected).not.toContain('target_far')
})
it('6.2 Rabies formulas: 5-band poison damage, +18%/blvl Poison Creeper synergy, 4..8s duration', () => {
const r1 = calculateRabiesStats(1, 0)
expect(r1.minPoisonDamage).toBe(6)
expect(r1.maxPoisonDamage).toBe(14)
expect(r1.poisonDurationFrames).toBe(100)
expect(r1.poisonDurationSeconds).toBe(4.0)
const r20 = calculateRabiesStats(20, 20)
expect(r20.synergyBonusPct).toBe(360)
expect(r20.poisonDurationFrames).toBe(290) // 100 + 19*10
expect(r20.poisonDurationSeconds).toBe(11.6)
})
it('6.3 [FAILING / BUG REPRO] executeSkill113c runtime infects hit target with rabies state', async () => {
const reg = await getSharedDataRegistry()
const caster = createTestUnit('dru_w', 'DruidWolf', 'player')
caster.stateBus.applyState({ stateNameOrId: 'wolf', slvl: 10, stats: {} })
const target = createTestUnit('m1', 'TargetMonster', 'monster', 1000)
const skill238 = reg.getSkillById(238)!
const eval238 = evaluateSkill113c({ registry: reg, skill: skill238, slvl: 10, blvl: 10, statList: caster.statList, stateBus: caster.stateBus })
const out238 = executeSkill113c({
registry: reg,
skill: skill238,
evalResult: eval238,
caster,
targets: [target],
targetPositions: new Map(),
corpses: [],
missileEngine: null as any,
auraScanner: null as any,
summonManager: null as any,
currentTick: 0,
targetX: 0,
targetY: 0,
})
// Hit target must receive rabies infection state
expect(target.stateBus.hasState('rabies')).toBe(true)
})
})
})