901 lines
39 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|