/** * 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() 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() 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() 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() 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() 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) } }) }) })