diablo2-web/tests/skills/nec/adv-curses-stress.test.ts

503 lines
19 KiB
TypeScript

/**
* Adversarial Challenger Stress & Boundary Suite: Necromancer Curses Tree
* Milestone M3: Skills 66, 71, 72, 76, 77, 81, 82, 86, 87, 91 (1.13c Ground Truth)
*
* Verifies:
* 1. High-density multi-monster curse overwrite cascades (50 monsters in 2:1 isometric ellipses).
* 2. Multi-curse rapid alternating application (Amp -> Decrep -> Weaken -> Lower Resist) across 100 cycles with 0 stat leakage.
* 3. Attract inviolability under 100 continuous attempts of competing curses.
* 4. Borderline physical immunity threshold transitions (99%, 100%, 109%, 110%, 119%, 120%, 130%).
* 5. Iron Maiden lethal melee reflection scenarios against high-damage and physical-immune monsters.
* 6. Difficulty duration scaling under Nightmare (50%) and Hell (25%) for Dim Vision.
* 7. Boss and Champion mental curse rejection in ActScene (Dim Vision, Terror, Confuse, Attract).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
ISO_GROUND_ASPECT_RATIO,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateIronMaidenReturnPct,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
} from '../../../src/game/skills.ts'
import {
computeEffectiveResistance,
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
import type { Monster, CombatTerrain } from '../../../src/game/combat.ts'
describe('Adversarial Challenger: Necromancer Curses Stress & Boundary Suite', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = { map: null }
const dummyTerrain: any = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
beforeEach(() => {
const dummyOpts: GameEngineOptions = {
spawn: { x: 200, y: 200 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 25,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 200
engine.world.player.y = 200
engine.world.player.cooldown = 0
engine.world.player.mana = 1000
statusElem = { textContent: '' } as any
})
// =========================================================================
// Challenge 1: High-Density 50-Monster 2:1 Isometric AoE Curse Applications
// =========================================================================
describe('Challenge 1: High-Density 50-Monster 2:1 Isometric AoE Curse Targeting', () => {
it('afflicts exactly monsters inside the 2:1 ground ellipse and excludes those outside', () => {
const radiusPx = calculateCurseRadiusPx(66, 1) // slvl 1 Amplify Damage: 80 px
const monsters: Monster[] = []
// Generate 50 monsters in radial bands
for (let i = 0; i < 50; i++) {
// Vary distance from 20 px to 160 px
const dist = 20 + i * 2.8
const angle = (i * 0.45)
const mx = 200 + Math.cos(angle) * dist
const my = 200 + Math.sin(angle) * (dist * ISO_GROUND_ASPECT_RATIO)
monsters.push({
index: i,
x: mx,
y: my,
hp: 500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: `fallen_${i}`, name: 'Fallen', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5 },
})
}
engine.world.monsters = monsters
// Cast Amplify Damage (66) at (200, 200)
const castSuccess = castSkill(66, 200, 200, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
})
expect(castSuccess).toBe(true)
// Verify each monster's curse state against 2:1 ground geometry
let afflictedCount = 0
for (const m of monsters) {
const isoDist = Math.hypot(m.x - 200, (m.y - 200) / ISO_GROUND_ASPECT_RATIO)
if (isoDist <= radiusPx) {
expect(m.curse?.name).toBe('amplifydamage')
expect(m.curse?.curseType).toBe(1)
afflictedCount++
} else {
expect(m.curse).toBeUndefined()
}
}
expect(afflictedCount).toBeGreaterThan(15)
expect(afflictedCount).toBeLessThan(50)
})
})
// =========================================================================
// Challenge 2: Rapid Alternating Multi-Curse Overwrites (100 Cycles)
// =========================================================================
describe('Challenge 2: 100 Cycles of Rapid Alternating Curse Overwrites with Zero Stat Leakage', () => {
it('alternates Amp -> Decrep -> Weaken -> Lower Resist 100 times without residual modifier accumulation', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
const bus = new StateBus(stats, registry)
for (let cycle = 0; cycle < 100; cycle++) {
// Step 1: Amplify Damage
const r1 = bus.applyState({
stateNameOrId: 'amplifydamage',
slvl: 20,
stats: { damageresist: -100, amplify_phys_pierce: 100 },
curseTypeOverride: 1,
})
expect(r1.applied).toBe(true)
expect(bus.hasState('amplifydamage')).toBe(true)
expect(stats.getModifierBonus('damageresist')).toBe(-100)
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(100)
expect(stats.getModifierBonus('damagepercent')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
// Step 2: Decrepify
const r2 = bus.applyState({
stateNameOrId: 'decrepify',
slvl: 20,
stats: { damageresist: -50, velocitypercent: -50, damagepercent: -50 },
curseTypeOverride: 1,
})
expect(r2.applied).toBe(true)
expect(bus.hasState('decrepify')).toBe(true)
expect(bus.hasState('amplifydamage')).toBe(false)
expect(stats.getModifierBonus('damageresist')).toBe(-50)
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(-50)
expect(stats.getModifierBonus('damagepercent')).toBe(-50)
// Step 3: Weaken
const r3 = bus.applyState({
stateNameOrId: 'weaken',
slvl: 20,
stats: { damagepercent: -33 },
curseTypeOverride: 1,
})
expect(r3.applied).toBe(true)
expect(bus.hasState('weaken')).toBe(true)
expect(bus.hasState('decrepify')).toBe(false)
expect(stats.getModifierBonus('damageresist')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
expect(stats.getModifierBonus('damagepercent')).toBe(-33)
// Step 4: Lower Resist
const r4 = bus.applyState({
stateNameOrId: 'lowerresist',
slvl: 20,
stats: { lowerresist_pierce: 63, fireresist: -63, lightresist: -63, coldresist: -63, poisonresist: -63 },
curseTypeOverride: 1,
})
expect(r4.applied).toBe(true)
expect(bus.hasState('lowerresist')).toBe(true)
expect(bus.hasState('weaken')).toBe(false)
expect(stats.getModifierBonus('damagepercent')).toBe(0)
expect(stats.getModifierBonus('lowerresist_pierce')).toBe(63)
expect(stats.getModifierBonus('fireresist')).toBe(-63)
}
// Final audit: only Lower Resist remains
expect(bus.getActiveEntries().length).toBe(1)
expect(bus.hasState('lowerresist')).toBe(true)
expect(stats.getModifierBonus('lowerresist_pierce')).toBe(63)
expect(stats.getModifierBonus('damageresist')).toBe(0)
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(0)
expect(stats.getModifierBonus('damagepercent')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
})
})
// =========================================================================
// Challenge 3: Attract Inviolability Under 100 Competing Attacks
// =========================================================================
describe('Challenge 3: Attract Inviolability Under 100 Competing Curse Attempts', () => {
it('defends Attract from 100 continuous attempts by competing curses and duplicates', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
const bus = new StateBus(stats, registry)
// Apply initial Attract
const initAttract = bus.applyState({
stateNameOrId: 'attract',
slvl: 10,
curseTypeOverride: 2,
durationFrames: 1110,
})
expect(initAttract.applied).toBe(true)
const competingCurses = [
'amplifydamage',
'decrepify',
'weaken',
'ironmaiden',
'terror',
'confuse',
'lifetap',
'lowerresist',
'dimvision',
'attract',
]
for (let i = 0; i < 100; i++) {
const curseToTest = competingCurses[i % competingCurses.length]!
const result = bus.applyState({
stateNameOrId: curseToTest,
slvl: 20,
curseTypeOverride: curseToTest === 'attract' ? 2 : 1,
})
expect(result.applied).toBe(false)
expect(result.blockedByAttract).toBe(true)
expect(bus.hasState('attract')).toBe(true)
}
// Attract retains original slvl
const activeAttract = bus.getState('attract')
expect(activeAttract).toBeDefined()
expect(activeAttract?.slvl).toBe(10)
expect(activeAttract?.durationFrames).toBe(1110)
})
})
// =========================================================================
// Challenge 4: Borderline Physical Immunity Threshold Transitions
// =========================================================================
describe('Challenge 4: Borderline Physical Immunity Threshold Transitions', () => {
it('verifies exact threshold boundaries under Amplify Damage (-100% / -20% on immune)', () => {
// 99%: non-immune -> 99 - 100 = -1% (not immune, full pierce)
const b99 = computeImmunityBreak(99, 100)
expect(b99.wasImmune).toBe(false)
expect(b99.effectiveRes).toBe(-1)
expect(b99.broken).toBe(false)
// 100%: immune -> 100 - 20 = 80% (broken!)
const b100 = computeImmunityBreak(100, 100)
expect(b100.wasImmune).toBe(true)
expect(b100.effectiveRes).toBe(80)
expect(b100.broken).toBe(true)
// 109%: immune -> 109 - 20 = 89% (broken!)
const b109 = computeImmunityBreak(109, 100)
expect(b109.effectiveRes).toBe(89)
expect(b109.broken).toBe(true)
// 110%: immune -> 110 - 20 = 90% (broken!)
const b110 = computeImmunityBreak(110, 100)
expect(b110.effectiveRes).toBe(90)
expect(b110.broken).toBe(true)
// 119%: immune -> 119 - 20 = 99% (broken!)
const b119 = computeImmunityBreak(119, 100)
expect(b119.effectiveRes).toBe(99)
expect(b119.broken).toBe(true)
// 120%: immune -> 120 - 20 = 100% (remains immune!)
const b120 = computeImmunityBreak(120, 100)
expect(b120.wasImmune).toBe(true)
expect(b120.effectiveRes).toBe(100)
expect(b120.broken).toBe(false)
// 130%: immune -> 130 - 20 = 110% (remains immune!)
const b130 = computeImmunityBreak(130, 100)
expect(b130.wasImmune).toBe(true)
expect(b130.effectiveRes).toBe(110)
expect(b130.broken).toBe(false)
})
it('verifies exact threshold boundaries under Decrepify (-50% / -10% on immune)', () => {
// 99%: non-immune -> 99 - 50 = 49%
const b99 = computeImmunityBreak(99, 50)
expect(b99.wasImmune).toBe(false)
expect(b99.effectiveRes).toBe(49)
// 100%: immune -> 100 - 10 = 90% (broken!)
const b100 = computeImmunityBreak(100, 50)
expect(b100.wasImmune).toBe(true)
expect(b100.effectiveRes).toBe(90)
expect(b100.broken).toBe(true)
// 109%: immune -> 109 - 10 = 99% (broken!)
const b109 = computeImmunityBreak(109, 50)
expect(b109.effectiveRes).toBe(99)
expect(b109.broken).toBe(true)
// 110%: immune -> 110 - 10 = 100% (remains immune!)
const b110 = computeImmunityBreak(110, 50)
expect(b110.effectiveRes).toBe(100)
expect(b110.broken).toBe(false)
})
})
// =========================================================================
// Challenge 5: Iron Maiden Lethal Reflection Scenarios
// =========================================================================
describe('Challenge 5: Iron Maiden Lethal Melee Reflection Scenarios', () => {
it('kills a high-damage monster on melee attack via lethal reflected damage', () => {
const monsterStats = new UnitStatList()
monsterStats.setHp256(500 * FIXED_ONE)
const monsterBus = new StateBus(monsterStats)
monsterBus.applyState({
stateNameOrId: 'ironmaiden',
slvl: 20, // 675% reflection
curseTypeOverride: 1,
})
const attacker: CombatUnitContext = {
id: 'lethal_monster',
name: 'Moon Lord',
statList: monsterStats,
stateBus: monsterBus,
unitType: 'monster',
}
const defenderStats = new UnitStatList()
defenderStats.setHp256(2000 * FIXED_ONE)
const defenderBus = new StateBus(defenderStats)
const defender: CombatUnitContext = {
id: 'hero',
name: 'Paladin',
statList: defenderStats,
stateBus: defenderBus,
unitType: 'player',
}
// Moon Lord attacks Paladin for 200 physical melee damage
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 0,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 200 * FIXED_ONE,
flatPhysMax256: 200 * FIXED_ONE,
autoHit: true,
})
// Raw reflection: 200 * 6.75 = 1350 damage!
// Attacker had 500 HP, so HP drops to 0!
expect(outcome.reflectedPhys256).toBe(1350 * FIXED_ONE)
expect(monsterStats.getHp256()).toBe(0)
})
it('inflicts 0 reflected damage on a 100% physical immune monster', () => {
const immuneMonsterStats = new UnitStatList()
immuneMonsterStats.setHp256(1000 * FIXED_ONE)
immuneMonsterStats.addStat('damageresist', 100) // Physical Immune!
const monsterBus = new StateBus(immuneMonsterStats)
monsterBus.applyState({
stateNameOrId: 'ironmaiden',
slvl: 20,
curseTypeOverride: 1,
})
const attacker: CombatUnitContext = {
id: 'ghost',
name: 'Ghost',
statList: immuneMonsterStats,
stateBus: monsterBus,
unitType: 'monster',
}
const defenderStats = new UnitStatList()
defenderStats.setHp256(2000 * FIXED_ONE)
const defender: CombatUnitContext = {
id: 'hero',
name: 'Paladin',
statList: defenderStats,
stateBus: new StateBus(defenderStats),
unitType: 'player',
}
// Ghost attacks Paladin
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 0,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 100 * FIXED_ONE,
flatPhysMax256: 100 * FIXED_ONE,
autoHit: true,
})
// Since ghost is 100% physical immune, reflected damage is 0!
expect(outcome.reflectedPhys256 ?? 0).toBe(0)
expect(immuneMonsterStats.getHp256()).toBe(1000 * FIXED_ONE)
})
})
// =========================================================================
// Challenge 6: Boss and Champion Mental Curse Immunity in ActScene
// =========================================================================
describe('Challenge 6: Boss and Champion Mental Curse Immunity in ActScene', () => {
it('afflicts normal monsters but skips bosses and champions for Dim Vision, Terror, Confuse, Attract', () => {
const normalMonster: Monster = {
index: 0,
x: 210,
y: 200,
hp: 500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie_normal', name: 'Zombie', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5, rank: 'normal' },
}
const champMonster: Monster = {
index: 1,
x: 215,
y: 200,
hp: 1500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie_champ', name: 'Zombie Champion', hp: 1500, damage: 20, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 20, rank: 'champion' },
}
const bossMonster: Monster = {
index: 2,
x: 220,
y: 200,
hp: 5000,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'andariel', name: 'Andariel', hp: 5000, damage: 50, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, rank: 'unique', type: 'boss', isBoss: true } as any,
}
engine.world.monsters = [normalMonster, champMonster, bossMonster]
// 1. Cast Dim Vision (71)
castSkill(71, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(normalMonster.curse?.name).toBe('dimvision')
expect(champMonster.curse).toBeUndefined()
expect(bossMonster.curse).toBeUndefined()
// 2. Cast Terror (77)
castSkill(77, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(normalMonster.curse?.name).toBe('terror')
expect(champMonster.curse).toBeUndefined()
expect(bossMonster.curse).toBeUndefined()
// 3. Cast Confuse (81)
castSkill(81, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(normalMonster.curse?.name).toBe('confuse')
expect(champMonster.curse).toBeUndefined()
expect(bossMonster.curse).toBeUndefined()
// 4. Attempt to cast Attract (86) on the boss
const attractBoss = castSkill(86, 220, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(attractBoss).toBe(false)
expect(bossMonster.curse).toBeUndefined()
// 5. Cast Decrepify (87) — Decrepify affects Act Bosses in Diablo II!
const decrepSuccess = castSkill(87, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(decrepSuccess).toBe(true)
expect(normalMonster.curse?.name).toBe('decrepify')
expect(champMonster.curse?.name).toBe('decrepify')
expect(bossMonster.curse?.name).toBe('decrepify') // Boss IS affected by Decrepify!
})
})
})