diablo2-web/tests/skills/ass/adv-ass-challenger2-stress....

901 lines
39 KiB
TypeScript

/**
* 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/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import {
evaluateAvoidanceAndBlock,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import {
evaluateSkill113c,
executeSkill113c,
} from '../../../src/game/skills/registry.ts'
import {
computeBurstOfSpeedFRW,
computeBurstOfSpeedIAS,
computeCloakOfShadowsDefenseBonus,
computeCloakOfShadowsDefenseReduction,
computeFadeCurseReduction,
computeFadePhysicalDamageReduction,
computeFadeResistances,
computeMindBlastConversionChance,
computeMindBlastDamage,
computeMindBlastStunDuration,
computePsychicHammerDamage,
computeWeaponBlockChance,
resolveMindBlastTarget,
resolveWeaponBlock,
} from '../../../src/game/skills/assassin-shadow.ts'
import {
ASSASSIN_TRAP_SKILL_IDS,
BLADE_SRC_DAM,
TrapManager,
evaluateBladeShieldStats,
evaluateChargedBoltSentryStats,
evaluateDeathSentryStats,
evaluateLightningSentryStats,
evaluateWakeOfFireStats,
evaluateWakeOfInfernoStats,
} from '../../../src/game/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)
})
})
})