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