886 lines
34 KiB
TypeScript
886 lines
34 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Adversarial Stress Testing 1: Amazon Skills
|
|
* Focus: Bow & Crossbow and Passive & Magic Trees
|
|
*
|
|
* Empirical challenger verification suite probing:
|
|
* 1. Guided Arrow zero-pierce invariant (pierceChancePct strictly 0, cannot pierce targets).
|
|
* 2. Strafe multi-arrow lock, sequential targeting, and ammo consumption.
|
|
* 3. Multi-Shot fan angle dispersion and central-2 proc limit.
|
|
* 4. Dodge, Avoid, Evade animation lock behavior and Fend interruption bug.
|
|
* 5. Synergy isolation: hard skill points vs soft skill points and item charges.
|
|
*
|
|
* Ground Truth:
|
|
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
|
|
* - D2MOO C++ reference: SkillAma.cpp, Missiles.cpp
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
calculateMagicArrowStats,
|
|
calculateFireArrowStats,
|
|
calculateColdArrowStats,
|
|
calculateMultipleShotStats,
|
|
calculateExplodingArrowStats,
|
|
calculateIceArrowStats,
|
|
calculateGuidedArrowStats,
|
|
calculateStrafeStats,
|
|
calculateImmolationArrowStats,
|
|
calculateFreezingArrowStats,
|
|
generateMultipleShotAngles,
|
|
isMultipleShotCenterArrow,
|
|
} from '../../../src/game/skills/amazon-bow.ts'
|
|
import {
|
|
calculateInnerSightStats,
|
|
calculateCriticalStrikeStats,
|
|
calculateDodgeStats,
|
|
calculateSlowMissilesStats,
|
|
calculateAvoidStats,
|
|
calculatePenetrateStats,
|
|
calculateDecoyStats,
|
|
calculateEvadeStats,
|
|
calculateValkyrieStats,
|
|
calculatePierceStats,
|
|
} from '../../../src/game/skills/amazon-passive.ts'
|
|
import {
|
|
calculateJabStats,
|
|
calculatePowerStrikeStats,
|
|
calculatePoisonJavelinStats,
|
|
calculateImpaleStats,
|
|
calculateLightningBoltStats,
|
|
calculateChargedStrikeStats,
|
|
calculatePlagueJavelinStats,
|
|
calculateFendStats,
|
|
calculateLightningStrikeStats,
|
|
calculateLightningFuryStats,
|
|
} from '../../../src/game/skills/amazon-javelin-spear.ts'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
evaluateAvoidanceAndBlock,
|
|
type CombatUnitContext,
|
|
type SUnitDmgPacket,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { AnimDispatcher } from '../../../src/game/engine/anim-dispatcher.ts'
|
|
import { evaluateSkill113c } from '../../../src/game/skills/registry.ts'
|
|
|
|
function createCombatTarget(
|
|
id: string,
|
|
pos: { x: number; y: number },
|
|
registry: any,
|
|
opts?: { hp?: number; isMoving?: boolean; def?: number; cannotBeFrozen?: boolean }
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList(registry, {
|
|
level: 80,
|
|
hitpoints: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
maxhp: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
armorclass: opts?.def ?? 400,
|
|
fireresist: 0,
|
|
coldresist: 0,
|
|
lightresist: 0,
|
|
poisonresist: 0,
|
|
damageresist: 0,
|
|
magicresist: 0,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
...(opts?.cannotBeFrozen ? { cannot_be_frozen: 1 } : {}),
|
|
})
|
|
const stateBus = new StateBus(statList, registry)
|
|
return {
|
|
id,
|
|
name: id,
|
|
statList,
|
|
stateBus,
|
|
isMoving: opts?.isMoving ?? false,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
}
|
|
}
|
|
|
|
describe('Adversarial Stress Testing 1: Amazon Bow & Passive Trees (1.13c Ground Truth)', () => {
|
|
// =========================================================================
|
|
// 1. Guided Arrow Zero-Pierce Invariant
|
|
// =========================================================================
|
|
describe('1. Guided Arrow: Zero-Pierce Invariant (1.13c Strict Parity)', () => {
|
|
it('strictly forces canPierce = false and pierceChancePct = 0 across all levels and boundaries', () => {
|
|
// Sweep slvl 1 to 50
|
|
for (let lvl = 1; lvl <= 50; lvl++) {
|
|
const stats = calculateGuidedArrowStats(lvl)
|
|
expect(stats.canPierce).toBe(false)
|
|
expect(stats.pierceChancePct).toBe(0)
|
|
expect(stats.alwaysHits).toBe(true)
|
|
expect(stats.autoHit).toBe(true)
|
|
expect(stats.enhancedDamagePct).toBe(5 * (lvl - 1))
|
|
}
|
|
|
|
// Edge case inputs: 0, negative, NaN
|
|
expect(calculateGuidedArrowStats(0).canPierce).toBe(false)
|
|
expect(calculateGuidedArrowStats(0).pierceChancePct).toBe(0)
|
|
expect(calculateGuidedArrowStats(-5).canPierce).toBe(false)
|
|
expect(calculateGuidedArrowStats(-5).pierceChancePct).toBe(0)
|
|
expect(calculateGuidedArrowStats(NaN).canPierce).toBe(false)
|
|
expect(calculateGuidedArrowStats(NaN).pierceChancePct).toBe(0)
|
|
})
|
|
|
|
it('rejects pierceChancePct injection and overrides 100% Pierce skill in MissileEngine', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createCombatTarget('amazon-player', { x: 0, y: 0 }, registry)
|
|
// Player with maxed Pierce skill (Skill 33) + Razortail (+33%)
|
|
owner.statList.setBaseSkillLevel(33, 20)
|
|
owner.statList.addStat('item_pierce', 33)
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 22,
|
|
attackKind: 'missile',
|
|
srcDam: 128,
|
|
physDamagePct: 95,
|
|
autoHit: true,
|
|
}
|
|
|
|
// Adversarial attempt: explicitly requesting 100% pierce on Guided Arrow
|
|
const missile = missileEngine.spawnMissile({
|
|
missileNameOrId: 'guidedarrow',
|
|
sourceSkillId: 22,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 100,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
pierceChancePct: 100,
|
|
})
|
|
|
|
expect(missile).not.toBeNull()
|
|
expect(missile!.canPierce).toBe(false)
|
|
expect(missile!.pierceChancePct).toBe(0)
|
|
})
|
|
|
|
it('empirically verifies Guided Arrow stops at 1st target in a 5-monster line and never pierces', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createCombatTarget('amazon-player', { x: 0, y: 0 }, registry)
|
|
owner.statList.setBaseSkillLevel(33, 20) // Max Pierce
|
|
|
|
// 5 monsters lined up along x-axis
|
|
const targets = [
|
|
createCombatTarget('target-1', { x: 30, y: 0 }, registry, { hp: 5000 }),
|
|
createCombatTarget('target-2', { x: 60, y: 0 }, registry, { hp: 5000 }),
|
|
createCombatTarget('target-3', { x: 90, y: 0 }, registry, { hp: 5000 }),
|
|
createCombatTarget('target-4', { x: 120, y: 0 }, registry, { hp: 5000 }),
|
|
createCombatTarget('target-5', { x: 150, y: 0 }, registry, { hp: 5000 }),
|
|
]
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
for (const t of targets) {
|
|
targetPositions.set(t.id, { x: t.x!, y: t.y! })
|
|
}
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 22,
|
|
attackKind: 'missile',
|
|
srcDam: 128,
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
}
|
|
|
|
const missile = missileEngine.spawnMissile({
|
|
missileNameOrId: 'guidedarrow',
|
|
sourceSkillId: 22,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 150,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
pierceChancePct: 100, // Adversarial 100% pierce
|
|
})!
|
|
|
|
let target1Hits = 0
|
|
let otherTargetsHits = 0
|
|
|
|
// Step until missile hits target-1
|
|
for (let tick = 1; tick <= 10; tick++) {
|
|
const step = missileEngine.tick(tick, targets, targetPositions)
|
|
for (const h of step.hits) {
|
|
if (h.targetId === 'target-1') {
|
|
target1Hits += 1
|
|
} else {
|
|
otherTargetsHits += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(target1Hits).toBe(1)
|
|
expect(otherTargetsHits).toBe(0) // Targets 2, 3, 4, 5 took 0 damage
|
|
expect(missile.expired).toBe(true)
|
|
expect(missile.pierceCount).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 2. Strafe Multi-Arrow Lock, Sequential Targeting & Ammo Consumption
|
|
// =========================================================================
|
|
describe('2. Strafe: Multi-Arrow Lock, Sequential Targeting & Ammo Consumption', () => {
|
|
it('verifies arrow count scaling min(10, 4 + floor(slvl/2)) and stationary lock-in duration', () => {
|
|
// slvl 1 -> 4
|
|
expect(calculateStrafeStats(1).arrowCount).toBe(4)
|
|
expect(calculateStrafeStats(1).lockInFrames).toBe(12) // 4*2 + 4 = 12
|
|
|
|
// slvl 2 -> 5
|
|
expect(calculateStrafeStats(2).arrowCount).toBe(5)
|
|
expect(calculateStrafeStats(2).lockInFrames).toBe(14) // 5*2 + 4 = 14
|
|
|
|
// slvl 3 -> 5
|
|
expect(calculateStrafeStats(3).arrowCount).toBe(5)
|
|
|
|
// slvl 10 -> 9
|
|
expect(calculateStrafeStats(10).arrowCount).toBe(9)
|
|
expect(calculateStrafeStats(10).lockInFrames).toBe(22)
|
|
|
|
// slvl 12 -> 10 (cap)
|
|
expect(calculateStrafeStats(12).arrowCount).toBe(10)
|
|
expect(calculateStrafeStats(12).lockInFrames).toBe(24) // 10*2 + 4 = 24
|
|
|
|
// slvl 20..50 -> 10
|
|
expect(calculateStrafeStats(20).arrowCount).toBe(10)
|
|
expect(calculateStrafeStats(50).arrowCount).toBe(10)
|
|
|
|
// Stationary lock-in invariant
|
|
expect(calculateStrafeStats(1).stationaryLockIn).toBe(true)
|
|
expect(calculateStrafeStats(20).stationaryLockIn).toBe(true)
|
|
expect(calculateStrafeStats(1).damageMultiplier).toBe(0.75)
|
|
expect(calculateStrafeStats(1).srcDam).toBe(96)
|
|
expect(calculateStrafeStats(1).manaCost).toBe(11)
|
|
})
|
|
|
|
it('cycles targeting sequentially across 3 alive targets and applies strafe_lockin state', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createCombatTarget('amazon-player', { x: 0, y: 0 }, registry)
|
|
const t1 = createCombatTarget('enemy-A', { x: 100, y: 0 }, registry)
|
|
const t2 = createCombatTarget('enemy-B', { x: 0, y: 100 }, registry)
|
|
const t3 = createCombatTarget('enemy-C', { x: -100, y: 0 }, registry)
|
|
const targets = [t1, t2, t3]
|
|
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(t1.id, { x: 100, y: 0 })
|
|
targetPositions.set(t2.id, { x: 0, y: 100 })
|
|
targetPositions.set(t3.id, { x: -100, y: 0 })
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 26,
|
|
attackKind: 'missile',
|
|
srcDam: 96,
|
|
autoHit: true,
|
|
}
|
|
|
|
const arrows = missileEngine.spawnStrafeVolley({
|
|
sourceSkillId: 26,
|
|
slvl: 20, // 10 arrows
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targets,
|
|
targetPositions,
|
|
dmgPacket,
|
|
})
|
|
|
|
expect(arrows.length).toBe(10)
|
|
expect(owner.stateBus.hasState('strafe_lockin')).toBe(true)
|
|
expect(owner.stateBus.getState('strafe_lockin')?.durationFrames).toBe(24)
|
|
|
|
// Sequential target distribution: targets cycle t1 -> t2 -> t3 -> t1 ...
|
|
// Arrow 0 -> t1 (angle: 0)
|
|
// Arrow 1 -> t2 (angle: PI/2)
|
|
// Arrow 2 -> t3 (angle: PI)
|
|
// Arrow 3 -> t1 (angle: 0)
|
|
expect(arrows[0]!.angleRad).toBeCloseTo(0)
|
|
expect(arrows[1]!.angleRad).toBeCloseTo(Math.PI / 2)
|
|
expect(arrows[2]!.angleRad).toBeCloseTo(Math.PI)
|
|
expect(arrows[3]!.angleRad).toBeCloseTo(0)
|
|
expect(arrows[4]!.angleRad).toBeCloseTo(Math.PI / 2)
|
|
expect(arrows[5]!.angleRad).toBeCloseTo(Math.PI)
|
|
})
|
|
|
|
it('filters dead enemies so Strafe never fires arrows at dead corpses', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createCombatTarget('amazon-player', { x: 0, y: 0 }, registry)
|
|
const aliveEnemy = createCombatTarget('alive-enemy', { x: 100, y: 50 }, registry, { hp: 5000 })
|
|
const deadEnemy = createCombatTarget('dead-corpse', { x: -100, y: -50 }, registry, { hp: 0 })
|
|
deadEnemy.statList.setHp256(0) // Dead
|
|
|
|
const targets = [aliveEnemy, deadEnemy]
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(aliveEnemy.id, { x: 100, y: 50 })
|
|
targetPositions.set(deadEnemy.id, { x: -100, y: -50 })
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 26,
|
|
attackKind: 'missile',
|
|
srcDam: 96,
|
|
autoHit: true,
|
|
}
|
|
|
|
const arrows = missileEngine.spawnStrafeVolley({
|
|
sourceSkillId: 26,
|
|
slvl: 20, // 10 arrows
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targets,
|
|
targetPositions,
|
|
dmgPacket,
|
|
})
|
|
|
|
expect(arrows.length).toBe(10)
|
|
// Every arrow must be aimed at aliveEnemy, zero arrows at deadEnemy
|
|
const expectedAngle = Math.atan2(50 / ISO_GROUND_ASPECT_RATIO, 100)
|
|
for (const arrow of arrows) {
|
|
expect(arrow.angleRad).toBeCloseTo(expectedAngle)
|
|
}
|
|
})
|
|
|
|
it('verifies 1.13c ammo deduction: Multiple Shot consumes 1 arrow per cast, Magic Arrow consumes 0, and records Strafe decquant discrepancy', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const skillStrafe = registry.getSkillById(26)!
|
|
const skillMultipleShot = registry.getSkillById(12)!
|
|
const skillMagicArrow = registry.getSkillById(6)!
|
|
|
|
// In 1.13c Skills.txt:
|
|
// - Multiple Shot (12): decquant = 1 (true)
|
|
// - Magic Arrow (6): decquant is blank/0 (false)
|
|
// - Strafe (26): decquant is blank in Skills.txt (false) because Blizzard's native engine
|
|
// handles ammo deduction manually in SKILLS_SrvSt08_Strafe via sub_6FD118C0.
|
|
expect(skillMultipleShot.decquant).toBe(true)
|
|
expect(skillMagicArrow.decquant).toBe(false)
|
|
expect(skillStrafe.decquant).toBe(false)
|
|
|
|
const playerStatList = new UnitStatList(registry, { level: 80, maxmana: 1000 * FIXED_ONE, mana: 1000 * FIXED_ONE })
|
|
const playerStateBus = new StateBus(playerStatList, registry)
|
|
|
|
const dispatcher = new AnimDispatcher({
|
|
registry,
|
|
statList: playerStatList,
|
|
stateBus: playerStateBus,
|
|
charToken: 'AM',
|
|
weaponClass: 'bow',
|
|
})
|
|
|
|
dispatcher.setAmmoQuantity(250)
|
|
expect(dispatcher.getAmmoQuantity()).toBe(250)
|
|
|
|
// 1. Multiple Shot cast: should consume exactly 1 arrow (250 -> 249) even though it spawns many arrows
|
|
const resMS = dispatcher.startSkillAnimation({
|
|
skill: skillMultipleShot,
|
|
slvl: 20,
|
|
currentTick: 1,
|
|
})
|
|
expect(resMS.allowed).toBe(true)
|
|
const animMS = resMS.animState!
|
|
|
|
for (let t = 1; t <= animMS.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(249)
|
|
|
|
// 2. Magic Arrow cast: decquant is false, 0 arrows consumed (remains 249)
|
|
const resMA = dispatcher.startSkillAnimation({
|
|
skill: skillMagicArrow,
|
|
slvl: 20,
|
|
currentTick: 50,
|
|
})
|
|
expect(resMA.allowed).toBe(true)
|
|
const animMA = resMA.animState!
|
|
|
|
for (let t = 50; t <= 50 + animMA.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(249)
|
|
|
|
// 3. Strafe cast: in AnimDispatcher, per D2MOO SKILLS_SrvSt08_Strafe sub_6FD118C0,
|
|
// casting Strafe consumes 1 arrow from ammo inventory (249 -> 248)
|
|
const resStrafe = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 20,
|
|
currentTick: 100,
|
|
})
|
|
expect(resStrafe.allowed).toBe(true)
|
|
const animStrafe = resStrafe.animState!
|
|
|
|
for (let t = 100; t <= 100 + animStrafe.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
// D2MOO Ground Truth: Strafe consumes exactly 1 arrow (249 -> 248)
|
|
expect(dispatcher.getAmmoQuantity()).toBe(248)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 3. Multi-Shot Fan Angle Dispersion & Central-2 Proc Limit
|
|
// =========================================================================
|
|
describe('3. Multi-Shot: Fan Angle Dispersion & Central-2 Proc Limit', () => {
|
|
it('generates symmetric monotonic fan dispersion centered at aim angle', () => {
|
|
// 7 arrows with 45 degree (PI/4) spread centered at 0 rad
|
|
const angles = generateMultipleShotAngles(0, 7, Math.PI / 4)
|
|
expect(angles.length).toBe(7)
|
|
expect(angles[3]).toBeCloseTo(0)
|
|
expect(angles[0]).toBeCloseTo(-Math.PI / 8)
|
|
expect(angles[6]).toBeCloseTo(Math.PI / 8)
|
|
|
|
// Symmetry check
|
|
for (let i = 0; i < 3; i++) {
|
|
expect(angles[i]! + angles[6 - i]!).toBeCloseTo(0)
|
|
}
|
|
|
|
// Monotonicity check
|
|
for (let i = 0; i < angles.length - 1; i++) {
|
|
expect(angles[i + 1]!).toBeGreaterThan(angles[i]!)
|
|
}
|
|
|
|
// Single arrow degenerate case
|
|
expect(generateMultipleShotAngles(Math.PI / 3, 1)).toEqual([Math.PI / 3])
|
|
})
|
|
|
|
it('enforces single-target hit immunity per Multiple Shot volley (anti-shotgunning)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createCombatTarget('amazon-player', { x: 0, y: 0 }, registry)
|
|
const target = createCombatTarget('boss-dummy', { x: 80, y: 0 }, registry)
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(target.id, { x: 80, y: 0 })
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
srcDam: 96,
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
}
|
|
|
|
// Spawn 24 arrows all directed through target at x=80
|
|
const arrows = missileEngine.spawnMultipleShotVolley({
|
|
sourceSkillId: 12,
|
|
slvl: 22,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 80,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
})
|
|
|
|
expect(arrows.length).toBe(24)
|
|
let totalHitsOnBoss = 0
|
|
for (let tick = 1; tick <= 10; tick++) {
|
|
const step = missileEngine.tick(tick, [target], targetPositions)
|
|
for (const h of step.hits) {
|
|
if (h.targetId === target.id) {
|
|
totalHitsOnBoss += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ground Truth: exactly 1 hit allowed per volley on the same target
|
|
expect(totalHitsOnBoss).toBe(1)
|
|
})
|
|
|
|
it('examines isMultipleShotCenterArrow proc flagging across even and odd arrow counts', () => {
|
|
// Even counts (e.g. 4, 6, 8, 24): exactly 2 center arrows
|
|
const centers4 = [0, 1, 2, 3].filter(i => isMultipleShotCenterArrow(i, 4))
|
|
expect(centers4).toEqual([1, 2])
|
|
expect(centers4.length).toBe(2)
|
|
|
|
const centers6 = [0, 1, 2, 3, 4, 5].filter(i => isMultipleShotCenterArrow(i, 6))
|
|
expect(centers6).toEqual([2, 3])
|
|
expect(centers6.length).toBe(2)
|
|
|
|
const centers24 = Array.from({ length: 24 }, (_, i) => i).filter(i => isMultipleShotCenterArrow(i, 24))
|
|
expect(centers24).toEqual([11, 12])
|
|
expect(centers24.length).toBe(2)
|
|
|
|
// Odd counts:
|
|
// In D2MOO SkillAma.cpp:515-560, v31 = dwCalc[2] = 2 (calc3 in Skills.txt).
|
|
// The center loop runs v31 times (always 2 arrows with proc flags).
|
|
// For arrowCount >= 2, exactly 2 central arrows are flagged without 0x10000 proc suppression.
|
|
const centers3 = [0, 1, 2].filter(i => isMultipleShotCenterArrow(i, 3))
|
|
const centers5 = [0, 1, 2, 3, 4].filter(i => isMultipleShotCenterArrow(i, 5))
|
|
const centers7 = [0, 1, 2, 3, 4, 5, 6].filter(i => isMultipleShotCenterArrow(i, 7))
|
|
|
|
expect(centers3).toEqual([0, 1])
|
|
expect(centers3.length).toBe(2)
|
|
expect(centers5).toEqual([1, 2])
|
|
expect(centers5.length).toBe(2)
|
|
expect(centers7).toEqual([2, 3])
|
|
expect(centers7.length).toBe(2)
|
|
})
|
|
|
|
it('verifies Multiple Shot proc and leech suppression on outer arrows (canTriggerProcs === false)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
// Attacker has Life Leech (10%) and Life Tap active on defender
|
|
const owner = createCombatTarget('amazon-player', { x: 0, y: 0 }, registry, { hp: 500 })
|
|
owner.statList.addStat('item_parasite', 10)
|
|
const target = createCombatTarget('dummy-monster', { x: 100, y: 0 }, registry, { hp: 1000 })
|
|
target.stateBus.applyState({
|
|
stateNameOrId: 'lifetap',
|
|
slvl: 1,
|
|
durationFrames: 500,
|
|
})
|
|
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(target.id, { x: 100, y: 0 })
|
|
|
|
// Multiple Shot slvl 1 creates 3 arrows: indices 0, 1 (center, canTriggerProcs=true) and 2 (outer, canTriggerProcs=false)
|
|
const arrows = missileEngine.spawnMultipleShotVolley({
|
|
sourceSkillId: 12,
|
|
slvl: 1,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 100,
|
|
targetY: 0,
|
|
dmgPacket: {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
knockback: true,
|
|
},
|
|
})
|
|
|
|
expect(arrows.length).toBe(3)
|
|
expect(arrows[0]!.canTriggerProcs).toBe(true)
|
|
expect(arrows[1]!.canTriggerProcs).toBe(true)
|
|
expect(arrows[2]!.canTriggerProcs).toBe(false)
|
|
expect(arrows[2]!.dmgPacket.canTriggerProcs).toBe(false)
|
|
|
|
// Direct combat check on center arrow vs outer arrow
|
|
const hpBefore = owner.statList.getHp256()
|
|
|
|
// 1. Center arrow hit (canTriggerProcs: true)
|
|
const resCenter = executeSUnitDmg(owner, target, arrows[0]!.dmgPacket)
|
|
expect(resCenter.hit).toBe(true)
|
|
expect(resCenter.procsSuppressed).toBeUndefined()
|
|
// Center arrow triggers leech (Life Tap 50% + item_parasite 10% = 60% of damage healed)
|
|
expect(resCenter.attackerHealed256).toBeGreaterThan(0)
|
|
expect(target.stateBus.hasState('knockback')).toBe(true)
|
|
|
|
// Reset owner HP and clear knockback
|
|
owner.statList.setHp256(hpBefore)
|
|
target.stateBus.removeState('knockback')
|
|
|
|
// 2. Outer arrow hit (canTriggerProcs: false)
|
|
const resOuter = executeSUnitDmg(owner, target, arrows[2]!.dmgPacket)
|
|
expect(resOuter.hit).toBe(true)
|
|
expect(resOuter.procsSuppressed).toBe(true)
|
|
// Outer arrow has leech, knockback, and procs strictly suppressed
|
|
expect(resOuter.attackerHealed256).toBeUndefined()
|
|
expect(owner.statList.getHp256()).toBe(hpBefore)
|
|
expect(target.stateBus.hasState('knockback')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 4. Dodge, Avoid, Evade Animation Lock & Fend Interruption Bug
|
|
// =========================================================================
|
|
describe('4. Dodge, Avoid, Evade: Animation Lock & Fend Interruption Bug', () => {
|
|
it('verifies Dodge: triggers only on stationary melee and applies GH animation lock', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const dStats = calculateDodgeStats(20)
|
|
|
|
expect(dStats.chancePct).toBe(56) // dm(10, 65, 20) = 56%
|
|
expect(dStats.triggersAnimationLock).toBe(true)
|
|
expect(dStats.animationCode).toBe('GH')
|
|
expect(dStats.appliesTo).toBe('melee')
|
|
expect(dStats.condition).toBe('stationary_or_attacking')
|
|
|
|
const stationaryAma = createCombatTarget('ama-still', { x: 0, y: 0 }, registry, { isMoving: false })
|
|
stationaryAma.statList.addStat('passive_dodge', 56)
|
|
|
|
// 1. Stationary melee attack avoided
|
|
const resMelee = evaluateAvoidanceAndBlock({
|
|
defender: stationaryAma,
|
|
attackKind: 'melee',
|
|
roll100: 30, // 30 < 56
|
|
})
|
|
expect(resMelee.avoided).toBe(true)
|
|
expect(resMelee.reason).toBe('dodge')
|
|
|
|
// 2. Stationary missile attack: Dodge does NOT trigger
|
|
const resMissile = evaluateAvoidanceAndBlock({
|
|
defender: stationaryAma,
|
|
attackKind: 'missile',
|
|
roll100: 30,
|
|
})
|
|
expect(resMissile.reason).not.toBe('dodge')
|
|
|
|
// 3. Moving Amazon: Dodge does NOT trigger
|
|
const movingAma = createCombatTarget('ama-moving', { x: 0, y: 0 }, registry, { isMoving: true })
|
|
movingAma.statList.addStat('passive_dodge', 56)
|
|
const resMoving = evaluateAvoidanceAndBlock({
|
|
defender: movingAma,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
expect(resMoving.reason).not.toBe('dodge')
|
|
})
|
|
|
|
it('verifies Avoid: triggers only on stationary missile and applies GH animation lock', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const aStats = calculateAvoidStats(20)
|
|
|
|
expect(aStats.chancePct).toBe(65) // dm(15, 75, 20) = 65%
|
|
expect(aStats.triggersAnimationLock).toBe(true)
|
|
expect(aStats.animationCode).toBe('GH')
|
|
expect(aStats.appliesTo).toBe('missile')
|
|
|
|
const stationaryAma = createCombatTarget('ama-still', { x: 0, y: 0 }, registry, { isMoving: false })
|
|
stationaryAma.statList.addStat('passive_avoid', 65)
|
|
|
|
// 1. Stationary missile avoided
|
|
const resMissile = evaluateAvoidanceAndBlock({
|
|
defender: stationaryAma,
|
|
attackKind: 'missile',
|
|
roll100: 40, // 40 < 65
|
|
})
|
|
expect(resMissile.avoided).toBe(true)
|
|
expect(resMissile.reason).toBe('avoid')
|
|
|
|
// 2. Stationary melee: Avoid does NOT trigger
|
|
const resMelee = evaluateAvoidanceAndBlock({
|
|
defender: stationaryAma,
|
|
attackKind: 'melee',
|
|
roll100: 40,
|
|
})
|
|
expect(resMelee.reason).not.toBe('avoid')
|
|
})
|
|
|
|
it('verifies Evade: triggers while moving against melee AND missile with ZERO animation lock', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const eStats = calculateEvadeStats(20)
|
|
|
|
expect(eStats.chancePct).toBe(56) // dm(10, 65, 20) = 56%
|
|
expect(eStats.triggersAnimationLock).toBe(false) // ZERO animation lock
|
|
expect(eStats.uninterrupted).toBe(true)
|
|
expect(eStats.condition).toBe('moving')
|
|
|
|
const movingAma = createCombatTarget('ama-moving', { x: 0, y: 0 }, registry, { isMoving: true })
|
|
movingAma.statList.addStat('passive_evade', 56)
|
|
|
|
// 1. Moving melee avoided
|
|
const resMelee = evaluateAvoidanceAndBlock({
|
|
defender: movingAma,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
expect(resMelee.avoided).toBe(true)
|
|
expect(resMelee.reason).toBe('evade')
|
|
|
|
// 2. Moving missile avoided
|
|
const resMissile = evaluateAvoidanceAndBlock({
|
|
defender: movingAma,
|
|
attackKind: 'missile',
|
|
roll100: 30,
|
|
})
|
|
expect(resMissile.avoided).toBe(true)
|
|
expect(resMissile.reason).toBe('evade')
|
|
|
|
// 3. Stationary Amazon: Evade does NOT trigger
|
|
const stillAma = createCombatTarget('ama-still', { x: 0, y: 0 }, registry, { isMoving: false })
|
|
stillAma.statList.addStat('passive_evade', 56)
|
|
const resStill = evaluateAvoidanceAndBlock({
|
|
defender: stillAma,
|
|
attackKind: 'melee',
|
|
roll100: 30,
|
|
})
|
|
expect(resStill.reason).not.toBe('evade')
|
|
})
|
|
|
|
it('verifies Fend (Skill 30) multi-target striking and evasion interruption abort contract', () => {
|
|
const fend1 = calculateFendStats(1)
|
|
expect(fend1.enhancedDamagePct).toBe(70)
|
|
expect(fend1.attackRatingBonusPct).toBe(40)
|
|
expect(fend1.maxAdjacentTargets).toBe(8)
|
|
expect(fend1.evasionInterruptible).toBe(true)
|
|
expect(fend1.fendBugActive).toBe(true)
|
|
expect(fend1.manaCost).toBe(5.0)
|
|
|
|
const fend20 = calculateFendStats(20, 8)
|
|
expect(fend20.enhancedDamagePct).toBe(260) // 70 + 10*19 = 260%
|
|
expect(fend20.attackRatingBonusPct).toBe(230) // 40 + 10*19 = 230%
|
|
expect(fend20.strikesCount).toBe(8)
|
|
|
|
// Simulated Fend Bug execution:
|
|
// Amazon strikes up to 8 enemies in sequence.
|
|
// If an enemy hits and triggers Dodge (13) on strike 2, the GH animation lock
|
|
// aborts all remaining strikes (strikes 3..8 are cancelled).
|
|
const strikesPlanned = fend20.strikesCount
|
|
let completedStrikes = 0
|
|
let fendAborted = false
|
|
|
|
for (let s = 1; s <= strikesPlanned; s++) {
|
|
if (s === 2) {
|
|
// Monster counter-attack triggers Dodge
|
|
const dodgeCheck = calculateDodgeStats(20)
|
|
if (fend20.fendBugActive && dodgeCheck.triggersAnimationLock) {
|
|
fendAborted = true
|
|
break // Sequence aborted!
|
|
}
|
|
}
|
|
completedStrikes++
|
|
}
|
|
|
|
expect(fendAborted).toBe(true)
|
|
expect(completedStrikes).toBe(1)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 5. Synergy Isolation: Hard Points vs Soft Points & Item Charges
|
|
// =========================================================================
|
|
describe('5. Synergy Isolation: Hard Points vs Soft Points & Item Charges', () => {
|
|
it('verifies Cold Arrow (11): +12%/blvl from Ice Arrow (21) strictly isolates hard points', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const skillRec = registry.getSkillById(11)!
|
|
|
|
// 1. With 20 HARD points in Ice Arrow (blvl = 20) -> +240% synergy
|
|
const statsHard = new UnitStatList()
|
|
statsHard.setBaseSkillLevel(11, 20)
|
|
statsHard.setBaseSkillLevel(21, 20)
|
|
const resHard = evaluateSkill113c({
|
|
registry,
|
|
skill: skillRec,
|
|
slvl: 20,
|
|
blvl: 20,
|
|
statList: statsHard,
|
|
})
|
|
expect(resHard.synergyBonusPct).toBe(240)
|
|
|
|
// 2. With 0 hard points, but +20 allskills, +5 single bonus, and 33 Marrowwalk charges
|
|
const statsSoft = new UnitStatList()
|
|
statsSoft.setBaseSkillLevel(11, 20)
|
|
statsSoft.addStat('item_allskills', 20)
|
|
statsSoft.setBonusSkillLevel(21, 5)
|
|
statsSoft.setChargedSkillLevel(21, 33)
|
|
const resSoft = evaluateSkill113c({
|
|
registry,
|
|
skill: skillRec,
|
|
slvl: 40,
|
|
blvl: 20,
|
|
statList: statsSoft,
|
|
})
|
|
expect(resSoft.synergyBonusPct).toBe(0) // Soft points grant STRICTLY 0%
|
|
})
|
|
|
|
it('verifies Exploding Arrow (16): +12%/blvl from Fire Arrow (7) strictly isolates hard points', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const skillRec = registry.getSkillById(16)!
|
|
|
|
// Hard points
|
|
const statsHard = new UnitStatList()
|
|
statsHard.setBaseSkillLevel(16, 20)
|
|
statsHard.setBaseSkillLevel(7, 20)
|
|
const resHard = evaluateSkill113c({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: statsHard })
|
|
expect(resHard.synergyBonusPct).toBe(240)
|
|
|
|
// Soft points
|
|
const statsSoft = new UnitStatList()
|
|
statsSoft.setBaseSkillLevel(16, 20)
|
|
statsSoft.addStat('item_allskills', 20)
|
|
statsSoft.setChargedSkillLevel(7, 33)
|
|
const resSoft = evaluateSkill113c({ registry, skill: skillRec, slvl: 40, blvl: 20, statList: statsSoft })
|
|
expect(resSoft.synergyBonusPct).toBe(0)
|
|
})
|
|
|
|
it('verifies Immolation Arrow (27): +5% explosion / +10% fire patch from Fire Arrow (7) isolates hard points', () => {
|
|
// Hard points
|
|
const synHard = calculateImmolationArrowStats(20, { fireArrow: 20 })
|
|
expect(synHard.explosionSynergyMultiplier).toBe(2.0) // +100% (20 * 5%)
|
|
expect(synHard.firePatchSynergyMultiplier).toBe(3.0) // +200% (20 * 10%)
|
|
|
|
// 0 hard points
|
|
const synZero = calculateImmolationArrowStats(20, { fireArrow: 0 })
|
|
expect(synZero.explosionSynergyMultiplier).toBe(1.0)
|
|
expect(synZero.firePatchSynergyMultiplier).toBe(1.0)
|
|
})
|
|
|
|
it('verifies Freezing Arrow (31): +12% dmg from Cold Arrow & +5% length from Ice Arrow strictly isolate hard points', () => {
|
|
const baseStats = calculateFreezingArrowStats(20)
|
|
expect(baseStats.coldSynergyMultiplier).toBe(1.0)
|
|
expect(baseStats.lenSynergyMultiplier).toBe(1.0)
|
|
expect(baseStats.freezeFrames).toBe(50)
|
|
|
|
// 20 hard points each
|
|
const synHard = calculateFreezingArrowStats(20, { coldArrow: 20, iceArrow: 20 })
|
|
expect(synHard.coldSynergyMultiplier).toBe(3.4) // +240%
|
|
expect(synHard.lenSynergyMultiplier).toBe(2.0) // +100%
|
|
expect(synHard.freezeFrames).toBe(100) // 50 * 2.0 = 100 frames (4.0s)
|
|
})
|
|
|
|
it('verifies Valkyrie (32): +20% life per hard point in Decoy (28) strictly isolates hard points', () => {
|
|
const baseValk = calculateValkyrieStats(20)
|
|
// Hard points = 20 -> +400% life
|
|
const valkHard = calculateValkyrieStats(20, { decoyHardPoints: 20 })
|
|
expect(valkHard.decoySynergyBonusPct).toBe(400)
|
|
expect(valkHard.finalHp).toBe(baseValk.baseHp * 5.0)
|
|
|
|
// 0 hard points
|
|
const valkZero = calculateValkyrieStats(20, { decoyHardPoints: 0 })
|
|
expect(valkZero.decoySynergyBonusPct).toBe(0)
|
|
expect(valkZero.finalHp).toBe(baseValk.baseHp)
|
|
})
|
|
|
|
it('verifies Javelin skills 4-way synergies isolate hard points in UnitStatList', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
// Power Strike (14) has 4 synergies: CS (24), LS (34), LF (35), LB (20) (+10%/lvl each)
|
|
const skillPS = registry.getSkillById(14)!
|
|
|
|
const statsHard = new UnitStatList()
|
|
statsHard.setBaseSkillLevel(14, 20)
|
|
statsHard.setBaseSkillLevel(24, 20)
|
|
statsHard.setBaseSkillLevel(34, 20)
|
|
statsHard.setBaseSkillLevel(35, 20)
|
|
statsHard.setBaseSkillLevel(20, 20)
|
|
const resHard = evaluateSkill113c({ registry, skill: skillPS, slvl: 20, blvl: 20, statList: statsHard })
|
|
expect(resHard.synergyBonusPct).toBe(800) // 80 * 10% = 800%
|
|
|
|
// Soft skills only
|
|
const statsSoft = new UnitStatList()
|
|
statsSoft.setBaseSkillLevel(14, 20)
|
|
statsSoft.addStat('item_allskills', 20)
|
|
statsSoft.setChargedSkillLevel(24, 33)
|
|
statsSoft.setChargedSkillLevel(34, 33)
|
|
const resSoft = evaluateSkill113c({ registry, skill: skillPS, slvl: 40, blvl: 20, statList: statsSoft })
|
|
expect(resSoft.synergyBonusPct).toBe(0)
|
|
})
|
|
|
|
it('verifies non-synergized skills strictly evaluate to 0% synergy under all conditions', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const nonSynergizedSkills = [22, 12, 26, 6, 10, 19, 30] // GA, MS, Strafe, MA, Jab, Impale, Fend
|
|
|
|
for (const id of nonSynergizedSkills) {
|
|
const skillRec = registry.getSkillById(id)!
|
|
const stats = new UnitStatList()
|
|
stats.setBaseSkillLevel(id, 20)
|
|
stats.addStat('item_allskills', 20)
|
|
const res = evaluateSkill113c({ registry, skill: skillRec, slvl: 40, blvl: 20, statList: stats })
|
|
expect(res.synergyBonusPct).toBe(0)
|
|
}
|
|
})
|
|
})
|
|
})
|