diablo2-web/tests/skills/ass/adv-ass-traps-stress.test.ts

561 lines
20 KiB
TypeScript

/**
* Adversarial Stress & Ground Truth Parity Test Suite — Assassin Traps (10 skills)
*
* Validates 1.13c Diablo II mechanics:
* 1. Global 5-trap cap on PetType `assassintrap` with strict FIFO eviction.
* 2. NextDelay mechanics (Wake of Fire 4-frame immunity, Shock Web 25-frame immunity).
* 3. Death Sentry dual-mode AI (corpse prioritization, 50/50 phys/fire split, radius progression, shot scaling).
* 4. Blade skills weapon damage scaling (3/8 for Sentinel, 3/4 for Fury, 1/4 for Shield).
* 5. Shock Web missile cluster dispersion and synergy scaling.
* 6. Sentry shot depletion and life cycles.
*
* Gitea Milestone: M26 (Issue #473)
*/
import { describe, expect, it } from 'vitest'
import {
ASSASSIN_TRAP_SKILL_IDS,
MAX_ACTIVE_TRAPS,
BLADE_SRC_DAM,
NEXT_DELAY_FRAMES,
TrapManager,
NextDelayTracker,
computeBladeWeaponDamage,
evaluateFireBlastStats,
evaluateShockWebStats,
evaluateBladeSentinelStats,
evaluateChargedBoltSentryStats,
evaluateWakeOfFireStats,
evaluateBladeFuryStats,
evaluateLightningSentryStats,
evaluateWakeOfInfernoStats,
evaluateDeathSentryStats,
evaluateBladeShieldStats,
simulateDeathSentryAI,
simulateWakeOfFireWaveHit,
computeShockWebClusterPoints,
type SimulationCorpse,
type SimulationEnemy,
} from '../../../src/game/skills/assassin-traps.ts'
describe('Assassin Traps Ground Truth & Adversarial Suite (Milestone M26)', () => {
describe('1. Global Trap Cap & FIFO Eviction (PetType: assassintrap, Limit: 5)', () => {
it('allows up to 5 traps without eviction', () => {
const manager = new TrapManager()
const ownerId = 'assassin_1'
// Summon 5 different traps
const trap1 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY,
summonMon: 'chargeboltsentry',
x: 100,
y: 100,
slvl: 20,
shots: 5,
currentFrame: 10,
})
expect(trap1.evictedTrap).toBeUndefined()
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
summonMon: 'wakeofdestruction',
x: 120,
y: 100,
slvl: 20,
shots: 5,
currentFrame: 20,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 140,
y: 100,
slvl: 20,
shots: 10,
currentFrame: 30,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_INFERNO,
summonMon: 'infernosentry',
x: 160,
y: 100,
slvl: 20,
shots: 10,
currentFrame: 40,
})
const trap5 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 180,
y: 100,
slvl: 20,
shots: 5,
currentFrame: 50,
})
expect(trap5.evictedTrap).toBeUndefined()
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
})
it('strictly evicts the oldest trap (FIFO) when a 6th trap is summoned', () => {
const manager = new TrapManager()
const ownerId = 'assassin_1'
// Register 5 traps
const t1 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 10,
y: 10,
slvl: 20,
shots: 10,
currentFrame: 10,
}).trap
const t2 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 20,
y: 10,
slvl: 20,
shots: 10,
currentFrame: 20,
}).trap
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 30,
y: 10,
slvl: 20,
shots: 10,
currentFrame: 30,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 40,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 40,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 50,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 50,
})
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
// Summon 6th trap -> t1 (the oldest) MUST be evicted!
const res6 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
summonMon: 'wakeofdestruction',
x: 60,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 60,
})
expect(res6.evictedTrap).toBeDefined()
expect(res6.evictedTrap?.id).toBe(t1.id)
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
// Active traps should NOT contain t1
const activeIds = manager.getActiveTraps(ownerId).map(t => t.id)
expect(activeIds).not.toContain(t1.id)
expect(activeIds).toContain(res6.trap.id)
// Summon 7th trap -> t2 MUST be evicted!
const res7 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY,
summonMon: 'chargeboltsentry',
x: 70,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 70,
})
expect(res7.evictedTrap?.id).toBe(t2.id)
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
})
it('isolates trap quotas per player/owner in multiplayer arena', () => {
const manager = new TrapManager()
const p1 = 'assassin_alice'
const p2 = 'assassin_bob'
for (let i = 0; i < 5; i++) {
manager.registerTrap({
ownerId: p1,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: i * 10,
y: 0,
slvl: 20,
shots: 10,
currentFrame: 0,
})
manager.registerTrap({
ownerId: p2,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: i * 10,
y: 50,
slvl: 20,
shots: 5,
currentFrame: 0,
})
}
expect(manager.getActiveTrapCount(p1)).toBe(5)
expect(manager.getActiveTrapCount(p2)).toBe(5)
// Summon 6th trap for p1; only p1's trap is evicted, p2's remain intact
const res = manager.registerTrap({
ownerId: p1,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
summonMon: 'wakeofdestruction',
x: 999,
y: 0,
slvl: 20,
shots: 5,
currentFrame: 100,
})
expect(res.evictedTrap?.ownerId).toBe(p1)
expect(manager.getActiveTrapCount(p1)).toBe(5)
expect(manager.getActiveTrapCount(p2)).toBe(5)
})
it('automatically destroys trap when shot count reaches zero', () => {
const manager = new TrapManager()
const ownerId = 'assassin_1'
const { trap } = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 0,
y: 0,
slvl: 1,
shots: 3,
currentFrame: 0,
})
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
const s1 = manager.consumeShot(trap.id)
expect(s1.remaining).toBe(2)
expect(s1.destroyed).toBe(false)
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
const s2 = manager.consumeShot(trap.id)
expect(s2.remaining).toBe(1)
expect(s2.destroyed).toBe(false)
const s3 = manager.consumeShot(trap.id)
expect(s3.remaining).toBe(0)
expect(s3.destroyed).toBe(true)
expect(manager.getActiveTrapCount(ownerId)).toBe(0)
})
})
describe('2. NextDelay Mechanics (Wake of Fire & Shock Web)', () => {
it('enforces 4-frame damage immunity for Wake of Fire', () => {
const tracker = new NextDelayTracker()
const enemyId = 'monster_1'
// At frame 100, monster is eligible
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 100)).toBe(true)
// Hit monster at frame 100 with NextDelay 4
tracker.recordHit(enemyId, 'wake_of_fire', 100, NEXT_DELAY_FRAMES.WAKE_OF_FIRE)
// Monster must be immune at frame 100, 101, 102, 103
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 100)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 101)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 102)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 103)).toBe(false)
// At frame 104, NextDelay window has expired (100 + 4 = 104)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 104)).toBe(true)
})
it('enforces 25-frame damage immunity for Shock Web', () => {
const tracker = new NextDelayTracker()
const enemyId = 'monster_boss'
expect(tracker.canTakeDamage(enemyId, 'shock_web', 500)).toBe(true)
tracker.recordHit(enemyId, 'shock_web', 500, NEXT_DELAY_FRAMES.SHOCK_WEB)
// Immune throughout frames 500..524
expect(tracker.canTakeDamage(enemyId, 'shock_web', 510)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'shock_web', 524)).toBe(false)
// Vulnerable again at frame 525 (500 + 25 = 525)
expect(tracker.canTakeDamage(enemyId, 'shock_web', 525)).toBe(true)
})
it('simulates Wake of Fire wave hit against grouped enemies with NextDelay', () => {
const tracker = new NextDelayTracker()
const enemies: SimulationEnemy[] = [
{ id: 'm1', x: 100, y: 100, currentHp: 500, maxHp: 500 },
{ id: 'm2', x: 150, y: 100, currentHp: 500, maxHp: 500 },
]
const trap: any = {
id: 'wof_1',
x: 0,
y: 100,
slvl: 20,
}
// First wave at frame 0
const hit0 = simulateWakeOfFireWaveHit(trap, enemies, 0, tracker)
expect(hit0.enemiesHit).toContain('m1')
expect(hit0.enemiesHit).toContain('m2')
expect(enemies[0]!.currentHp).toBeLessThan(500)
// Rapid overlap wave at frame 2 (within 4-frame NextDelay window) -> deals 0 damage!
const hpBefore = enemies[0]!.currentHp
const hit2 = simulateWakeOfFireWaveHit(trap, enemies, 2, tracker)
expect(hit2.enemiesHit).toHaveLength(0)
expect(hit2.totalDamageDealt).toBe(0)
expect(enemies[0]!.currentHp).toBe(hpBefore)
// Next wave at frame 5 (> 4 frames) -> hits again!
const hit5 = simulateWakeOfFireWaveHit(trap, enemies, 5, tracker)
expect(hit5.enemiesHit).toContain('m1')
expect(enemies[0]!.currentHp).toBeLessThan(hpBefore)
})
})
describe('3. Death Sentry Dual-Mode Corpse Explosion AI', () => {
it('prioritizes Corpse Explosion over Lightning when a corpse is in radius', () => {
const trap: any = { id: 'ds_1', x: 100, y: 100, slvl: 20 }
const corpses: SimulationCorpse[] = [
{ id: 'corpse_1', x: 120, y: 100, baseMaxHp: 1000, consumed: false },
]
const enemies: SimulationEnemy[] = [
{ id: 'live_mon', x: 130, y: 100, currentHp: 800, maxHp: 800 },
]
const decision = simulateDeathSentryAI(trap, corpses, enemies)
expect(decision.action).toBe('corpse_explosion')
expect(decision.targetId).toBe('corpse_1')
expect(corpses[0]!.consumed).toBe(true)
// 40-80% of 1000 HP -> 600 damage avg (300 phys, 300 fire)
expect(decision.damageDealt?.phys).toBe(300)
expect(decision.damageDealt?.fire).toBe(300)
expect(decision.damageDealt?.ltng).toBe(0)
expect(enemies[0]!.currentHp).toBe(200)
})
it('falls back to piercing lightning bolt when no valid corpse exists in radius', () => {
const trap: any = { id: 'ds_1', x: 0, y: 0, slvl: 20 }
const corpses: SimulationCorpse[] = [
// Corpse is far away outside radius (> 29 subtiles * 16px = 464px)
{ id: 'corpse_far', x: 900, y: 900, baseMaxHp: 1000, consumed: false },
]
const enemies: SimulationEnemy[] = [
{ id: 'near_mon', x: 100, y: 0, currentHp: 500, maxHp: 500 },
]
const decision = simulateDeathSentryAI(trap, corpses, enemies)
expect(decision.action).toBe('lightning')
expect(decision.targetId).toBe('near_mon')
expect(decision.damageDealt?.ltng).toBeGreaterThan(0)
expect(decision.damageDealt?.phys).toBe(0)
expect(decision.damageDealt?.fire).toBe(0)
expect(corpses[0]!.consumed).toBe(false)
})
it('evaluates Death Sentry shot count scaling with Fire Blast synergy', () => {
// Base shots: 5. +1 shot per 3 hard points of Fire Blast
const s0 = evaluateDeathSentryStats(20, { fireBlast: 0 })
expect(s0.shots).toBe(5)
const s2 = evaluateDeathSentryStats(20, { fireBlast: 2 })
expect(s2.shots).toBe(5)
const s3 = evaluateDeathSentryStats(20, { fireBlast: 3 })
expect(s3.shots).toBe(6)
const s9 = evaluateDeathSentryStats(20, { fireBlast: 9 })
expect(s9.shots).toBe(8)
const s20 = evaluateDeathSentryStats(20, { fireBlast: 20 })
expect(s20.shots).toBe(11) // 5 + floor(20 / 3) = 5 + 6 = 11 shots
})
it('verifies Corpse Explosion radius progression: 10 + (slvl - 1) subtiles', () => {
const s1 = evaluateDeathSentryStats(1)
expect(s1.corpseExplosionRadiusSubtiles).toBe(10) // 10 subtiles = 3.3 yards
const s10 = evaluateDeathSentryStats(10)
expect(s10.corpseExplosionRadiusSubtiles).toBe(19) // 19 subtiles = 6.3 yards
const s20 = evaluateDeathSentryStats(20)
expect(s20.corpseExplosionRadiusSubtiles).toBe(29) // 29 subtiles = 9.6 yards
})
})
describe('4. Blade Skills Weapon Damage Scaling (srcDam 128ths)', () => {
it('scales Blade Sentinel weapon damage to exact 3/8 (37.5%)', () => {
expect(BLADE_SRC_DAM.BLADE_SENTINEL).toBe(48)
// 48 / 128 = 3/8
// Weapon damage 80 -> 80 * 3/8 = 30
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(30)
// Weapon damage 240 -> 240 * 3/8 = 90
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(90)
// Weapon damage 100 -> floor(100 * 48 / 128) = floor(37.5) = 37
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(37)
})
it('scales Blade Fury weapon damage to exact 3/4 (75.0%)', () => {
expect(BLADE_SRC_DAM.BLADE_FURY).toBe(96)
// 96 / 128 = 3/4
// Weapon damage 80 -> 80 * 3/4 = 60
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(60)
// Weapon damage 240 -> 240 * 3/4 = 180
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(180)
// Weapon damage 100 -> floor(100 * 96 / 128) = 75
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(75)
})
it('scales Blade Shield weapon damage to exact 1/4 (25.0%)', () => {
expect(BLADE_SRC_DAM.BLADE_SHIELD).toBe(32)
// 32 / 128 = 1/4
// Weapon damage 80 -> 80 * 1/4 = 20
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(20)
// Weapon damage 240 -> 240 * 1/4 = 60
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(60)
// Weapon damage 100 -> floor(100 * 32 / 128) = 25
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(25)
})
it('combines flat physical damage with scaled weapon damage on Blade skills', () => {
const sentStats = evaluateBladeSentinelStats(20, 200)
// flat min = 79, max = 83. scaled weapon (200 * 3/8) = 75
expect(sentStats.scaledWeaponPhys).toBe(75)
expect(sentStats.totalMinPhys).toBe(79 + 75)
expect(sentStats.totalMaxPhys).toBe(83 + 75)
const furyStats = evaluateBladeFuryStats(20, 200)
// flat min = 101, max = 103. scaled weapon (200 * 3/4) = 150
expect(furyStats.scaledWeaponPhys).toBe(150)
expect(furyStats.totalMinPhys).toBe(101 + 150)
expect(furyStats.totalMaxPhys).toBe(103 + 150)
const shieldStats = evaluateBladeShieldStats(20, 200)
// flat min = 112, max = 141. scaled weapon (200 * 1/4) = 50
expect(shieldStats.scaledWeaponPhys).toBe(50)
expect(shieldStats.totalMinPhys).toBe(112 + 50)
expect(shieldStats.totalMaxPhys).toBe(141 + 50)
})
})
describe('5. Shock Web Cluster & Synergy Mechanics', () => {
it('calculates exact missile count: 6 + floor(slvl / 4) + floor(FireBlast.blvl / 3)', () => {
const sw1 = evaluateShockWebStats(1, { fireBlast: 0 })
expect(sw1.missileCount).toBe(6)
const sw4 = evaluateShockWebStats(4, { fireBlast: 0 })
expect(sw4.missileCount).toBe(7)
const sw20 = evaluateShockWebStats(20, { fireBlast: 0 })
expect(sw20.missileCount).toBe(11) // 6 + 5 = 11
const sw20_syn = evaluateShockWebStats(20, { fireBlast: 20 })
expect(sw20_syn.missileCount).toBe(17) // 6 + 5 + 6 = 17
})
it('generates distributed cluster points around impact origin', () => {
const points = computeShockWebClusterPoints(200, 200, 20, 20, 64)
expect(points).toHaveLength(17)
// Ensure all points lie within max cluster radius (64 + epsilon)
for (const pt of points) {
const dist = Math.hypot(pt.x - 200, pt.y - 200)
expect(dist).toBeLessThanOrEqual(65)
}
})
it('applies +11% synergy per hard point of CBS, LS, DS', () => {
const base = evaluateShockWebStats(20, { chargedBoltSentry: 0, lightningSentry: 0, deathSentry: 0 })
const syn = evaluateShockWebStats(20, { chargedBoltSentry: 20, lightningSentry: 20, deathSentry: 20 })
// 60 hard points * 11% = 660% bonus damage
expect(syn.synergyPct).toBe(660)
expect(syn.maxDamage).toBe(Math.floor(base.maxDamage * (1 + 6.6)))
})
})
describe('6. Sentry Lifecycles & Synergies', () => {
it('evaluates Charged Bolt Sentry shots and bolt count scaling', () => {
const cbs = evaluateChargedBoltSentryStats(20, {
lightningSentry: 20, // +5 shots
shockWeb: 20, // +6 bolts
})
// Base shots: 5 + floor(20/4) = 10 shots
expect(cbs.shots).toBe(10)
// Base bolts: 5 + floor(20/3) = 11 bolts per shot
expect(cbs.boltsPerShot).toBe(11)
})
it('evaluates Lightning Sentry piercing damage and 12% synergy scaling', () => {
const lsBase = evaluateLightningSentryStats(20, { shockWeb: 0, chargedBoltSentry: 0, deathSentry: 0 })
expect(lsBase.shots).toBe(10)
expect(lsBase.minDamage).toBe(1)
expect(lsBase.maxDamage).toBe(314) // 5-band level 20 max
const lsMaxSyn = evaluateLightningSentryStats(20, { shockWeb: 20, chargedBoltSentry: 20, deathSentry: 20 })
// 60 * 12% = 720% bonus
expect(lsMaxSyn.synergyPct).toBe(720)
expect(lsMaxSyn.maxDamage).toBe(Math.floor(314 * (1 + 7.2)))
})
it('evaluates Wake of Inferno 10 attacks and 7% synergies', () => {
const woi = evaluateWakeOfInfernoStats(20, { fireBlast: 20, wakeOfFire: 20, deathSentry: 20 })
expect(woi.attacks).toBe(10)
// 60 * 7% = 420% synergy
expect(woi.synergyPct).toBe(420)
})
it('evaluates Blade Shield 20s-115s duration and 25-frame tick rate', () => {
const bs1 = evaluateBladeShieldStats(1)
expect(bs1.durationFrames).toBe(500) // 20s
expect(bs1.tickRateFrames).toBe(25) // 1.0s
expect(bs1.radiusSubtiles).toBe(6)
const bs20 = evaluateBladeShieldStats(20)
expect(bs20.durationFrames).toBe(2875) // 500 + 125 * 19 = 2875 frames = 115s
expect(bs20.tickRateFrames).toBe(25)
expect(bs20.manaCost).toBe(65) // 27 + 2 * 19 = 65
})
})
})