diablo2-web/tests/skills/ama/adv-ama-adversarial-stress-...

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)
}
})
})
})