/** * Diablo II: Lord of Destruction v1.13c — Cohort 7 Assassin Shadow Disciplines, Traps & Sentry Caps * Challenger 2 (Gen 2) Adversarial Stress Testing & Differential Oracle Verification Suite * * Ground Truth: * - D2Game/src/SKILLS/SkillAss.cpp, SUnitDmg.cpp, AiThink.cpp (D2MOO decompiled binary assembly) * - Patch_D2.mpq: Skills.txt, Missiles.txt, States.txt, PetType.txt * * ZERO MOCKS: No vi.mock or vi.spyOn used. * ZERO CIRCULAR ORACLES: Independent 1.13c mathematical oracles cross-checked against live runtime * (`executeSkill113c`, `StateBus`, `SummonManager`, `UnitStatList`, `executeSUnitDmg`, `evaluateAvoidanceAndBlock`). */ import { describe, expect, it } from 'vitest' 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 { SummonManager } from '../../../src/server/ai/summon-manager.ts' import { MissileEngine } from '../../../src/server/engine/missile-engine.ts' import { AuraScanner } from '../../../src/server/engine/aura-scanner.ts' import { evaluateAvoidanceAndBlock, type CombatUnitContext, } from '../../../src/server/engine/combat-pipeline.ts' import { evaluateSkill113c, executeSkill113c, } from '../../../src/server/skills/registry.ts' import { computeBurstOfSpeedFRW, computeBurstOfSpeedIAS, computeCloakOfShadowsDefenseBonus, computeCloakOfShadowsDefenseReduction, computeFadeCurseReduction, computeFadePhysicalDamageReduction, computeFadeResistances, computeMindBlastConversionChance, computeMindBlastDamage, computeMindBlastStunDuration, computePsychicHammerDamage, computeWeaponBlockChance, resolveMindBlastTarget, resolveWeaponBlock, } from '../../../src/server/skills/assassin-shadow.ts' import { ASSASSIN_TRAP_SKILL_IDS, BLADE_SRC_DAM, TrapManager, evaluateBladeShieldStats, evaluateChargedBoltSentryStats, evaluateDeathSentryStats, evaluateLightningSentryStats, evaluateWakeOfFireStats, evaluateWakeOfInfernoStats, } from '../../../src/server/skills/assassin-traps.ts' /** * Independent 1.13c D2Common SKILLS_GetDiminishingReturns oracle: * trunc((110 * slvl * (max - min)) / (100 * (slvl + 6))) + min */ function oracleDm113c(min: number, max: number, slvl: number): number { return Math.trunc((110 * slvl * (max - min)) / (100 * (slvl + 6))) + min } describe('Challenger 2 — Cohort 7 Assassin Shadow Disciplines, Traps & Sentry Caps Stress Suite', () => { // ========================================================================= // 1. Burst of Speed vs Fade StateGroup 2 Mutual Exclusivity, Curse Reduction & PDR // ========================================================================= describe('1. Burst of Speed vs Fade StateGroup 2 Mutual Exclusivity, Curse Resistance & PDR', () => { it('1.1 verifies casting Burst of Speed removes Fade and casting Fade removes Burst of Speed in live StateBus', async () => { const registry = await getSharedDataRegistry() const bosRec = registry.getSkillById(258)! // Quickness (Burst of Speed) const fadeRec = registry.getSkillById(267)! // Fade const statList = new UnitStatList(registry) const stateBus = new StateBus(statList, registry) const caster: CombatUnitContext = { id: 'ass_hero', name: 'Assassin', statList, stateBus, } // Cast Fade (slvl 15) first const evalFade = evaluateSkill113c({ registry, skill: fadeRec, slvl: 15, blvl: 15, statList }) const outFade1 = executeSkill113c({ registry, skill: fadeRec, evalResult: evalFade, caster, targets: [], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 10, targetX: 0, targetY: 0, }) expect(outFade1.executed).toBe(true) expect(stateBus.hasState('fade')).toBe(true) expect(stateBus.hasState('quickness')).toBe(false) expect(statList.getAccruedStat('damageresist')).toBe(15) const expectedRes15 = oracleDm113c(10, 75, 15) expect(statList.getAccruedStat('fireresist')).toBe(expectedRes15) expect(statList.getAccruedStat('coldresist')).toBe(expectedRes15) expect(statList.getAccruedStat('lightresist')).toBe(expectedRes15) expect(statList.getAccruedStat('poisonresist')).toBe(expectedRes15) // Cast Burst of Speed (slvl 12) -> must evict Fade from StateGroup 2 and strip Fade's stat modifiers const evalBoS = evaluateSkill113c({ registry, skill: bosRec, slvl: 12, blvl: 12, statList }) const outBoS = executeSkill113c({ registry, skill: bosRec, evalResult: evalBoS, caster, targets: [], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 20, targetX: 0, targetY: 0, }) expect(outBoS.executed).toBe(true) expect(stateBus.hasState('quickness')).toBe(true) expect(stateBus.hasState('fade')).toBe(false) expect(statList.getAccruedStat('damageresist')).toBe(0) expect(statList.getAccruedStat('fireresist')).toBe(0) expect(statList.getAccruedStat('coldresist')).toBe(0) expect(statList.getAccruedStat('lightresist')).toBe(0) expect(statList.getAccruedStat('poisonresist')).toBe(0) expect(statList.getAccruedStat('velocitypercent')).toBe(oracleDm113c(15, 70, 12)) expect(statList.getAccruedStat('attackrate')).toBe(oracleDm113c(15, 60, 12)) // Cast Fade (slvl 20) again -> must evict Burst of Speed from StateGroup 2 and strip FRW/IAS const evalFade20 = evaluateSkill113c({ registry, skill: fadeRec, slvl: 20, blvl: 20, statList }) const outFade2 = executeSkill113c({ registry, skill: fadeRec, evalResult: evalFade20, caster, targets: [], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 30, targetX: 0, targetY: 0, }) expect(outFade2.executed).toBe(true) expect(stateBus.hasState('fade')).toBe(true) expect(stateBus.hasState('quickness')).toBe(false) expect(statList.getAccruedStat('velocitypercent')).toBe(0) expect(statList.getAccruedStat('attackrate')).toBe(0) expect(statList.getAccruedStat('damageresist')).toBe(20) }) it('1.2 verifies Fade curse_resistance shortens incoming curse duration and +1% PDR per slvl across slvl 1..30', async () => { const registry = await getSharedDataRegistry() const fadeRec = registry.getSkillById(267)! for (let slvl = 1; slvl <= 30; slvl++) { const statList = new UnitStatList(registry) const stateBus = new StateBus(statList, registry) const caster: CombatUnitContext = { id: `ass_${slvl}`, name: 'Assassin', statList, stateBus } const evalFade = evaluateSkill113c({ registry, skill: fadeRec, slvl, blvl: slvl, statList }) executeSkill113c({ registry, skill: fadeRec, evalResult: evalFade, caster, targets: [], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 1, targetX: 0, targetY: 0, }) // Verify hidden +1% PDR per slvl const expectedPdr = slvl expect(computeFadePhysicalDamageReduction(slvl)).toBe(expectedPdr) expect(statList.getAccruedStat('damageresist')).toBe(expectedPdr) // Verify all 4 elemental resists match dm(10, 75, slvl) const expectedRes = oracleDm113c(10, 75, slvl) expect(computeFadeResistances(slvl)).toBe(expectedRes) expect(statList.getAccruedStat('fireresist')).toBe(expectedRes) expect(statList.getAccruedStat('coldresist')).toBe(expectedRes) expect(statList.getAccruedStat('lightresist')).toBe(expectedRes) expect(statList.getAccruedStat('poisonresist')).toBe(expectedRes) // Verify curse_resistance stat matches dm(40, 90, slvl) const expectedCurseRes = oracleDm113c(40, 90, slvl) expect(computeFadeCurseReduction(slvl)).toBe(expectedCurseRes) expect(statList.getAccruedStat('curse_resistance')).toBe(expectedCurseRes) // Apply Amplify Damage (1000 frames, curseType = 1) and verify duration reduction const curseRes = stateBus.applyState({ stateNameOrId: 'amplifydamage', slvl: 10, durationFrames: 1000, currentFrame: 10, curseTypeOverride: 1, }) expect(curseRes.applied).toBe(true) const expectedDuration = Math.max(1, Math.floor((1000 * (100 - expectedCurseRes)) / 100)) expect(stateBus.getState('amplifydamage')?.durationFrames).toBe(expectedDuration) } }) }) // ========================================================================= // 2. Weapon Block Movement Zeroing // ========================================================================= describe('2. Weapon Block Movement Zeroing (Standing/Attacking/Casting vs Walking/Running)', () => { it('2.1 verifies Weapon Block operates while standing, attacking, casting and drops to strictly 0% while walking AND running across slvl 1..30', () => { for (let slvl = 1; slvl <= 30; slvl++) { const expectedChance = Math.min(65, oracleDm113c(20, 65, slvl)) expect(computeWeaponBlockChance(slvl)).toBe(expectedChance) // Active stationary modes with dual claws for (const mode of ['standing', 'attacking', 'casting'] as const) { const res = resolveWeaponBlock(slvl, true, mode) expect(res.canBlock).toBe(true) expect(res.effectiveChancePct).toBe(expectedChance) } // Movement modes MUST drop Weapon Block to strictly 0% for (const moveMode of ['walking', 'running'] as const) { const res = resolveWeaponBlock(slvl, true, moveMode) expect(res.canBlock).toBe(false) expect(res.effectiveChancePct).toBe(0) } // Without dual claws, Weapon Block is strictly 0% in all modes for (const mode of ['standing', 'attacking', 'casting', 'walking', 'running'] as const) { const res = resolveWeaponBlock(slvl, false, mode) expect(res.canBlock).toBe(false) expect(res.effectiveChancePct).toBe(0) } } }) it('2.2 verifies combat pipeline evaluateAvoidanceAndBlock blocks stationary dual-claw attacks and never blocks while moving', async () => { const registry = await getSharedDataRegistry() const defStats = new UnitStatList(registry) defStats.setBaseStat('weaponblock', 58) // slvl 20 Weapon Block const defBus = new StateBus(defStats, registry) // Stationary dual-claw defender with roll = 10 (< 58) blocks melee, missile, and spell hits const stationaryDef: CombatUnitContext = { id: 'ass_stationary', name: 'Assassin', statList: defStats, stateBus: defBus, isDualClaw: true, isMoving: false, } expect(evaluateAvoidanceAndBlock({ defender: stationaryDef, attackKind: 'melee', roll100: 10 }).avoided).toBe(true) expect(evaluateAvoidanceAndBlock({ defender: stationaryDef, attackKind: 'melee', roll100: 10 }).reason).toBe('weapon_block') expect(evaluateAvoidanceAndBlock({ defender: stationaryDef, attackKind: 'missile', roll100: 10 }).reason).toBe('weapon_block') expect(evaluateAvoidanceAndBlock({ defender: stationaryDef, attackKind: 'spell', roll100: 10 }).reason).toBe('weapon_block') // Moving dual-claw defender (walking or running -> isMoving: true) with roll = 0 gets 0% weapon block const movingDef: CombatUnitContext = { id: 'ass_moving', name: 'Assassin', statList: defStats, stateBus: defBus, isDualClaw: true, isMoving: true, } expect(evaluateAvoidanceAndBlock({ defender: movingDef, attackKind: 'melee', roll100: 0 }).avoided).toBe(false) expect(evaluateAvoidanceAndBlock({ defender: movingDef, attackKind: 'missile', roll100: 0 }).avoided).toBe(false) expect(evaluateAvoidanceAndBlock({ defender: movingDef, attackKind: 'spell', roll100: 0 }).avoided).toBe(false) }) }) // ========================================================================= // 3. Cloak of Shadows Recast Lock & Buffs // ========================================================================= describe('3. Cloak of Shadows Recast Lock & Buffs/Debuffs', () => { it('3.1 verifies casting Cloak of Shadows while already active fails with { executed: false, error: "already_active" } and applies caster buff + enemy cloaked debuff', async () => { const registry = await getSharedDataRegistry() const cosRec = registry.getSkillById(264)! const casterStats = new UnitStatList(registry) const casterBus = new StateBus(casterStats, registry) const caster: CombatUnitContext = { id: 'ass_cos', name: 'Assassin', statList: casterStats, stateBus: casterBus } const enemyStats = new UnitStatList(registry) enemyStats.setBaseStat('hitpoints', 500 * FIXED_ONE) const enemyBus = new StateBus(enemyStats, registry) const enemy: CombatUnitContext = { id: 'mon_1', name: 'Dark Hunter', statList: enemyStats, stateBus: enemyBus } const slvl = 15 const evalCoS = evaluateSkill113c({ registry, skill: cosRec, slvl, blvl: slvl, statList: casterStats }) // 1st cast succeeds const firstOutcome = executeSkill113c({ registry, skill: cosRec, evalResult: evalCoS, caster, targets: [enemy], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 100, targetX: 0, targetY: 0, }) expect(firstOutcome.executed).toBe(true) expect(firstOutcome.error).toBeUndefined() expect(casterBus.hasState('cloak_of_shadows')).toBe(true) // Caster defense bonus: 10 + 3 * (slvl - 1) = 10 + 42 = 52% const expectedDefBuff = 10 + 3 * (slvl - 1) expect(computeCloakOfShadowsDefenseBonus(slvl)).toBe(expectedDefBuff) expect(casterStats.getAccruedStat('item_armor_percent')).toBe(expectedDefBuff) // Enemy receives 'cloaked' debuff with defense reduction: -min(15 + 3*(slvl-1), 95) = -57% const expectedDefReduction = Math.min(15 + 3 * (slvl - 1), 95) expect(computeCloakOfShadowsDefenseReduction(slvl)).toBe(expectedDefReduction) expect(enemyBus.hasState('cloaked')).toBe(true) expect(enemyStats.getAccruedStat('skill_armor_percent')).toBe(-expectedDefReduction) // 2nd cast while 'cloak_of_shadows' is active MUST fail with { executed: false, error: 'already_active' } const secondOutcome = executeSkill113c({ registry, skill: cosRec, evalResult: evalCoS, caster, targets: [enemy], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 150, targetX: 0, targetY: 0, }) expect(secondOutcome.executed).toBe(false) expect(secondOutcome.error).toBe('already_active') expect(secondOutcome.manaSpent256).toBe(0) // After state expires, recasting succeeds again casterBus.tick(2000) expect(casterBus.hasState('cloak_of_shadows')).toBe(false) const thirdOutcome = executeSkill113c({ registry, skill: cosRec, evalResult: evalCoS, caster, targets: [enemy], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 2150, targetX: 0, targetY: 0, }) expect(thirdOutcome.executed).toBe(true) }) }) // ========================================================================= // 4. Mind Blast & Psychic Hammer (srvDoFunc = 51 & 33) // ========================================================================= describe('4. Mind Blast (srvDoFunc = 51) & Psychic Hammer (srvDoFunc = 33)', () => { it('4.1 verifies Psychic Hammer deals 50% physical / 50% magic damage and pushes target back with knockback', async () => { const registry = await getSharedDataRegistry() const phRec = registry.getSkillById(253)! // Psychic Hammer for (const slvl of [1, 10, 20]) { const dmg = computePsychicHammerDamage(slvl) expect(dmg.minPhys).toBe(dmg.minMagic) expect(dmg.maxPhys).toBe(dmg.maxMagic) const casterStats = new UnitStatList(registry) const casterBus = new StateBus(casterStats, registry) const caster: CombatUnitContext = { id: 'ass_ph', name: 'Assassin', statList: casterStats, stateBus: casterBus } const targetStats = new UnitStatList(registry) targetStats.setBaseStat('hitpoints', 5000 * FIXED_ONE) targetStats.setBaseStat('maxhp', 5000 * FIXED_ONE) const targetBus = new StateBus(targetStats, registry) const target: CombatUnitContext = { id: 'mon_ph', name: 'Skeleton', statList: targetStats, stateBus: targetBus } const evalPH = evaluateSkill113c({ registry, skill: phRec, slvl, blvl: slvl, statList: casterStats }) expect(evalPH.elemType).toBe('mag') const outcome = executeSkill113c({ registry, skill: phRec, evalResult: evalPH, caster, targets: [target], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 10, targetX: 0, targetY: 0, }) expect(outcome.executed).toBe(true) expect(outcome.srvDoFuncUsed).toBe(33) expect(outcome.totalDamageDealt).toBeGreaterThanOrEqual(dmg.minPhys + dmg.minMagic) expect(outcome.totalDamageDealt).toBeLessThanOrEqual(dmg.maxPhys + dmg.maxMagic) expect(targetBus.hasState('knockback')).toBe(true) // Verify 50% physical / 50% magic split by testing against 100% physical immune vs 100% magic immune targets const physImmStats = new UnitStatList(registry) physImmStats.setBaseStat('hitpoints', 5000 * FIXED_ONE) physImmStats.setBaseStat('maxhp', 5000 * FIXED_ONE) physImmStats.setBaseStat('damageresist', 100) const physImmTarget: CombatUnitContext = { id: 'mon_pi', name: 'Ghost', statList: physImmStats, stateBus: new StateBus(physImmStats, registry) } const magImmStats = new UnitStatList(registry) magImmStats.setBaseStat('hitpoints', 5000 * FIXED_ONE) magImmStats.setBaseStat('maxhp', 5000 * FIXED_ONE) magImmStats.setBaseStat('magicresist', 100) const magImmTarget: CombatUnitContext = { id: 'mon_mi', name: 'Wailing Beast', statList: magImmStats, stateBus: new StateBus(magImmStats, registry) } const outVsPhysImm = executeSkill113c({ registry, skill: phRec, evalResult: evalPH, caster, targets: [physImmTarget], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 11, targetX: 0, targetY: 0, }) const outVsMagImm = executeSkill113c({ registry, skill: phRec, evalResult: evalPH, caster, targets: [magImmTarget], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 12, targetX: 0, targetY: 0, }) // Physical immune takes strictly the magic half; Magic immune takes strictly the physical half (50/50 split) expect(outVsPhysImm.totalDamageDealt).toBeGreaterThan(0) expect(outVsPhysImm.totalDamageDealt).toBe(outVsMagImm.totalDamageDealt) } }) it('4.2 verifies Mind Blast deals physical damage, applies radial stun, and converts non-boss targets with diminishing-returns chance while bosses remain conversion-immune', async () => { const registry = await getSharedDataRegistry() const mbRec = registry.getSkillById(273)! // Mind Blast for (let slvl = 1; slvl <= 25; slvl++) { expect(computeMindBlastConversionChance(slvl)).toBe(oracleDm113c(15, 40, slvl)) expect(computeMindBlastStunDuration(slvl)).toBe(50 + 5 * (slvl - 1)) } const slvl = 20 const casterStats = new UnitStatList(registry) const casterBus = new StateBus(casterStats, registry) const caster: CombatUnitContext = { id: 'ass_mb', name: 'Assassin', statList: casterStats, stateBus: casterBus } // Create 15 normal monsters and 3 boss/unique/champion monsters const normalTargets: CombatUnitContext[] = [] for (let i = 0; i < 15; i++) { const st = new UnitStatList(registry) st.setBaseStat('hitpoints', 5000 * FIXED_ONE) st.setBaseStat('maxhp', 5000 * FIXED_ONE) const bus = new StateBus(st, registry) normalTargets.push({ id: `norm_${i}`, name: `Fallen_${i}`, statList: st, stateBus: bus }) } const bossTargets: CombatUnitContext[] = [ { id: 'boss_1', name: 'Baal', statList: new UnitStatList(registry), stateBus: undefined as any, isBoss: true, rank: 'boss' }, { id: 'champ_1', name: 'Champ', statList: new UnitStatList(registry), stateBus: undefined as any, isChampion: true, rank: 'champion' }, { id: 'uniq_1', name: 'Unique', statList: new UnitStatList(registry), stateBus: undefined as any, rank: 'unique' }, ] for (const b of bossTargets) { b.statList.setBaseStat('hitpoints', 5000 * FIXED_ONE) b.statList.setBaseStat('maxhp', 5000 * FIXED_ONE) ;(b as any).stateBus = new StateBus(b.statList, registry) } const evalMB = evaluateSkill113c({ registry, skill: mbRec, slvl, blvl: slvl, statList: casterStats }) const mbDmg = computeMindBlastDamage(slvl) expect(evalMB.minPhysDmg).toBe(mbDmg.minPhys) expect(evalMB.maxPhysDmg).toBe(mbDmg.maxPhys) const outcome = executeSkill113c({ registry, skill: mbRec, evalResult: evalMB, caster, targets: [...normalTargets, ...bossTargets], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 50, targetX: 0, targetY: 0, }) expect(outcome.executed).toBe(true) expect(outcome.srvDoFuncUsed).toBe(51) expect(outcome.totalDamageDealt).toBeGreaterThan(0) // Every normal and boss target receives radial stun (145 frames at slvl 20) for (const t of [...normalTargets, ...bossTargets]) { expect(t.stateBus.hasState('stun')).toBe(true) expect(t.stateBus.getState('stun')?.durationFrames).toBe(145) } // Boss/Champion/Unique targets MUST NEVER be converted for (const b of bossTargets) { expect(b.stateBus.hasState('conversion')).toBe(false) } // Deterministic check via resolveMindBlastTarget expect(resolveMindBlastTarget(20, { roll: 0 }).converted).toBe(true) expect(resolveMindBlastTarget(20, { roll: 35 }).converted).toBe(true) expect(resolveMindBlastTarget(20, { roll: 36 }).converted).toBe(false) expect(resolveMindBlastTarget(20, { isBoss: true, roll: 0 }).converted).toBe(false) expect(resolveMindBlastTarget(20, { isUnique: true, roll: 0 }).converted).toBe(false) expect(resolveMindBlastTarget(20, { isChampion: true, roll: 0 }).converted).toBe(false) }) }) // ========================================================================= // 5. 5-Trap Cap & FIFO Eviction (Sentries + Blade Sentinel) // ========================================================================= describe('5. 5-Trap Cap & FIFO Eviction (CBS, WoF, LS, WoI, DS, Blade Sentinel)', () => { it('5.1 enforces global cap of 5 traps and strict FIFO eviction across all 6 trap skills in SummonManager and TrapManager', async () => { const registry = await getSharedDataRegistry() const summonManager = new SummonManager(registry) const trapManager = new TrapManager() const casterStats = new UnitStatList(registry) const casterBus = new StateBus(casterStats, registry) const caster: CombatUnitContext = { id: 'ass_trapper', name: 'Assassin', statList: casterStats, stateBus: casterBus } const trapSequence = [ ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY, // 261 (1st) ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE, // 262 (2nd) ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, // 271 (3rd) ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_INFERNO, // 272 (4th) ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY, // 276 (5th) ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL, // 257 (6th -> evicts 1st) ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, // 271 (7th -> evicts 2nd) ] const createdUids: string[] = [] for (let i = 0; i < trapSequence.length; i++) { const skillId = trapSequence[i]! const res = summonManager.createPet({ owner: caster, skillId, slvl: 20, x: i * 20, y: 0, }) expect(res.created).toBe(true) expect(res.pet?.petType).toBe('assassintrap') createdUids.push(res.pet!.uid) const tRes = trapManager.registerTrap({ ownerId: caster.id, skillId: skillId as any, summonMon: res.pet!.monId, x: i * 20, y: 0, slvl: 20, shots: 10, currentFrame: i * 10, }) const activePets = summonManager.getActivePets().filter(p => p.petType === 'assassintrap') if (i < 5) { expect(res.evictedPetUids.length).toBe(0) expect(tRes.evictedTrap).toBeUndefined() expect(activePets.length).toBe(i + 1) expect(trapManager.getActiveTrapCount(caster.id)).toBe(i + 1) } else { // 6th and 7th traps evict the oldest active trap in FIFO order expect(res.evictedPetUids).toEqual([createdUids[i - 5]!]) expect(activePets.length).toBe(5) expect(trapManager.getActiveTrapCount(caster.id)).toBe(5) const expectedRemainingUids = createdUids.slice(i - 4, i + 1) expect(activePets.map(p => p.uid)).toEqual(expectedRemainingUids) } } }) }) // ========================================================================= // 6. Sentry Initial Shot Counts // ========================================================================= describe('6. Sentry Initial Shot Counts (WoF, DS, CBS, LS, WoI)', () => { it('6.1 verifies exact initial shot formulas and hard-point blvl synergy in both evaluators and SummonManager', async () => { const registry = await getSharedDataRegistry() for (let blvl = 0; blvl <= 25; blvl++) { const summonManager = new SummonManager(registry) const statList = new UnitStatList(registry) // Set hard points in Fire Blast (251) and Lightning Sentry (271) statList.setBaseSkillLevel(251, blvl) statList.setBaseSkillLevel(271, blvl) // Add +15 soft allskills and +30 charges to ensure soft skills/charges do NOT inflate shot counts statList.addStat('item_allskills', 15) statList.setChargedSkillLevel(251, 30) statList.setChargedSkillLevel(271, 30) const stateBus = new StateBus(statList, registry) const caster: CombatUnitContext = { id: `ass_shots_${blvl}`, name: 'Assassin', statList, stateBus } // 1. Wake of Fire (262): strictly 5 shots expect(evaluateWakeOfFireStats(20).shots).toBe(5) const wofPet = summonManager.createPet({ owner: caster, skillId: 262, slvl: 20, x: 0, y: 0 }).pet! expect(wofPet.shotsRemaining).toBe(5) // 2. Death Sentry (276): 5 + floor(FireBlast.blvl / 3) const expectedDsShots = 5 + Math.floor(blvl / 3) expect(evaluateDeathSentryStats(20, { fireBlast: blvl }).shots).toBe(expectedDsShots) const dsPet = summonManager.createPet({ owner: caster, skillId: 276, slvl: 20, x: 10, y: 0 }).pet! expect(dsPet.shotsRemaining).toBe(expectedDsShots) // 3. Charged Bolt Sentry (261): 5 + floor(LS.blvl / 4) const expectedCbsShots = 5 + Math.floor(blvl / 4) expect(evaluateChargedBoltSentryStats(20, { lightningSentry: blvl }).shots).toBe(expectedCbsShots) const cbsPet = summonManager.createPet({ owner: caster, skillId: 261, slvl: 20, x: 20, y: 0 }).pet! expect(cbsPet.shotsRemaining).toBe(expectedCbsShots) // 4. Lightning Sentry (271): strictly 10 shots expect(evaluateLightningSentryStats(20).shots).toBe(10) const lsPet = summonManager.createPet({ owner: caster, skillId: 271, slvl: 20, x: 30, y: 0 }).pet! expect(lsPet.shotsRemaining).toBe(10) // 5. Wake of Inferno (272): strictly 10 shots expect(evaluateWakeOfInfernoStats(20).attacks).toBe(10) const woiPet = summonManager.createPet({ owner: caster, skillId: 272, slvl: 20, x: 40, y: 0 }).pet! expect(woiPet.shotsRemaining).toBe(10) } }) }) // ========================================================================= // 7. Death Sentry Corpse Explosion // ========================================================================= describe('7. Death Sentry Corpse Explosion (Corpse Rejection, 40%-80% HP, 50/50 Phys/Fire, Radius 10+(slvl-1))', () => { it('7.1 rejects shattered, consumed, and boss corpses, and detonates valid corpse for 40%-80% base HP (50% phys / 50% fire) in radius 10+(slvl-1) subtiles', async () => { const registry = await getSharedDataRegistry() const summonManager = new SummonManager(registry) const missileEngine = new MissileEngine(registry) const auraScanner = new AuraScanner(registry) const casterStats = new UnitStatList(registry) const casterBus = new StateBus(casterStats, registry) const caster: CombatUnitContext = { id: 'ass_ds', name: 'Assassin', statList: casterStats, stateBus: casterBus } const slvl = 15 // Radius at slvl 15 = 10 + 14 = 24 subtiles = 24 * 16 = 384 px expect(evaluateDeathSentryStats(slvl).corpseExplosionRadiusSubtiles).toBe(24) const dsPet = summonManager.createPet({ owner: caster, skillId: 276, slvl, x: 0, y: 0, }).pet! expect(dsPet).toBeDefined() // Invalid corpses (shattered, consumed, boss) inside radius const shatteredCorpse = { id: 'c_shattered', x: 16, y: 0, baseMaxHp: 2000, consumed: false, shattered: true, isBoss: false } const bossCorpse = { id: 'c_boss', x: 32, y: 0, baseMaxHp: 2000, consumed: false, shattered: false, isBoss: true } const consumedCorpse = { id: 'c_consumed', x: 48, y: 0, baseMaxHp: 2000, consumed: true, shattered: false, isBoss: false } // Enemy in range (dist = 100px <= 384px) and enemy out of CE blast range (dist = 500px > 384px from corpse) const makeEnemy = (id: string, physRes = 0, fireRes = 0): CombatUnitContext => { const st = new UnitStatList(registry) st.setBaseStat('hitpoints', 5000 * FIXED_ONE) st.setBaseStat('maxhp', 5000 * FIXED_ONE) if (physRes !== 0) st.setBaseStat('damageresist', physRes) if (fireRes !== 0) st.setBaseStat('fireresist', fireRes) return { id, name: id, statList: st, stateBus: new StateBus(st, registry) } } // Tick 1: Only invalid corpses present -> Death Sentry must NOT consume any of them, falls back to lightning bolt const enemyTest = makeEnemy('enemy_fallback') const tickInvalid = summonManager.tickPets({ currentTick: 25, owner: caster, enemies: [enemyTest], enemyPositions: new Map([['enemy_fallback', { x: 100, y: 0 }]]), corpses: [shatteredCorpse, bossCorpse, consumedCorpse], missileEngine, auraScanner, }) expect(shatteredCorpse.consumed).toBe(false) expect(bossCorpse.consumed).toBe(false) expect(tickInvalid.corpsesConsumedByPets).toBe(0) // Tick 2: Add a valid corpse (baseMaxHp = 1000) at (0, 0) // Enemy 1 at (380, 0) -> dist = 380px <= 384px (inside 24-subtile radius) // Enemy 2 at (390, 0) -> dist = 390px > 384px (outside 24-subtile radius) // Enemy 3 (phys immune 100% DR, 0% fire res) at (100, 0) -> takes strictly the 50% fire portion (200..400) // Enemy 4 (fire immune 100% FR, 0% phys res) at (100, 0) -> takes strictly the 50% phys portion (200..400) const validCorpse = { id: 'c_valid', x: 0, y: 0, baseMaxHp: 1000, consumed: false, shattered: false, isBoss: false } const enemyInside = makeEnemy('enemy_in', 0, 0) const enemyOutside = makeEnemy('enemy_out', 0, 0) const enemyPhysImmune = makeEnemy('enemy_phys_imm', 100, 0) const enemyFireImmune = makeEnemy('enemy_fire_imm', 0, 100) const posMap = new Map([ ['enemy_in', { x: 380, y: 0 }], ['enemy_out', { x: 390, y: 0 }], ['enemy_phys_imm', { x: 100, y: 0 }], ['enemy_fire_imm', { x: 100, y: 0 }], ]) const tickValid = summonManager.tickPets({ currentTick: 60, owner: caster, enemies: [enemyInside, enemyOutside, enemyPhysImmune, enemyFireImmune], enemyPositions: posMap, corpses: [shatteredCorpse, bossCorpse, consumedCorpse, validCorpse], missileEngine, auraScanner, }) expect(tickValid.corpsesConsumedByPets).toBe(1) expect(validCorpse.consumed).toBe(true) expect(shatteredCorpse.consumed).toBe(false) expect(bossCorpse.consumed).toBe(false) // Enemy inside radius took 400..800 total damage (40%..80% of 1000 HP) const dmgInside = 5000 - Math.floor(enemyInside.statList.getHp256() / FIXED_ONE) expect(dmgInside).toBeGreaterThanOrEqual(400) expect(dmgInside).toBeLessThanOrEqual(800) // Enemy outside radius (390px > 384px) took 0 damage const dmgOutside = 5000 - Math.floor(enemyOutside.statList.getHp256() / FIXED_ONE) expect(dmgOutside).toBe(0) // Phys immune enemy took strictly 50% fire portion (200..400) const dmgPhysImm = 5000 - Math.floor(enemyPhysImmune.statList.getHp256() / FIXED_ONE) expect(dmgPhysImm).toBeGreaterThanOrEqual(200) expect(dmgPhysImm).toBeLessThanOrEqual(400) // Fire immune enemy took strictly 50% physical portion (200..400) const dmgFireImm = 5000 - Math.floor(enemyFireImmune.statList.getHp256() / FIXED_ONE) expect(dmgFireImm).toBeGreaterThanOrEqual(200) expect(dmgFireImm).toBeLessThanOrEqual(400) expect(dmgFireImm).toBe(dmgPhysImm) }) }) // ========================================================================= // 8. Blade Shield Durability Drain & Physical Damage (srcDam = 32) // ========================================================================= describe('8. Blade Shield Physical Damage (srcDam = 32) & Weapon Durability Drain', () => { it('8.1 verifies Blade Shield deals physical damage with srcDam = 32 (1/4 weapon damage) and drains 1 equipped weapon durability per contact hit', async () => { const registry = await getSharedDataRegistry() const bsRec = registry.getSkillById(277)! // Blade Shield expect(bsRec.srcDam).toBe(32) expect(BLADE_SRC_DAM.BLADE_SHIELD).toBe(32) const casterStats = new UnitStatList(registry) // Equip weapon with 100..100 base physical damage and 50 durability casterStats.setBaseStat('mindamage', 100) casterStats.setBaseStat('maxdamage', 100) casterStats.setBaseStat('durability', 50) casterStats.setBaseStat('maxdurability', 50) const casterBus = new StateBus(casterStats, registry) const caster: CombatUnitContext = { id: 'ass_bs', name: 'Assassin', statList: casterStats, stateBus: casterBus, weaponMinPhys: 100, weaponMaxPhys: 100, } const targetStats = new UnitStatList(registry) targetStats.setBaseStat('hitpoints', 5000 * FIXED_ONE) targetStats.setBaseStat('maxhp', 5000 * FIXED_ONE) const targetBus = new StateBus(targetStats, registry) const target: CombatUnitContext = { id: 'mon_bs', name: 'Fallen', statList: targetStats, stateBus: targetBus } const slvl = 20 const evalBS = evaluateSkill113c({ registry, skill: bsRec, slvl, blvl: slvl, statList: casterStats }) const bsExpected = evaluateBladeShieldStats(slvl, 100) // At slvl 20: flat phys = 112..141, weapon scaled (100 * 32 / 128) = 25 -> total 137..166 expect(bsExpected.scaledWeaponPhys).toBe(25) expect(bsExpected.totalMinPhys).toBe(137) expect(bsExpected.totalMaxPhys).toBe(166) const outcome = executeSkill113c({ registry, skill: bsRec, evalResult: evalBS, caster, targets: [target], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 10, targetX: 0, targetY: 0, }) expect(outcome.executed).toBe(true) expect(casterBus.hasState('bladeshield')).toBe(true) expect(outcome.totalDamageDealt).toBeGreaterThanOrEqual(bsExpected.totalMinPhys) expect(outcome.totalDamageDealt).toBeLessThanOrEqual(bsExpected.totalMaxPhys) // Equipped weapon durability drained by 1 (from 50 -> 49) expect(casterStats.getAccruedStat('durability')).toBe(49) // Drain down to 0 and verify floor at 0 casterStats.setBaseStat('durability', 1) executeSkill113c({ registry, skill: bsRec, evalResult: evalBS, caster, targets: [target], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 35, targetX: 0, targetY: 0, }) expect(casterStats.getAccruedStat('durability')).toBe(0) // Further hits at 0 durability do not underflow below 0 executeSkill113c({ registry, skill: bsRec, evalResult: evalBS, caster, targets: [target], targetPositions: new Map(), corpses: [], missileEngine: {} as any, auraScanner: {} as any, summonManager: {} as any, currentTick: 60, targetX: 0, targetY: 0, }) expect(casterStats.getAccruedStat('durability')).toBe(0) }) }) })