577 lines
21 KiB
TypeScript
577 lines
21 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Adversarial Challenger Verification Suite: Iteration 2
|
|
* Focus: Amazon Bow & Passive Skills Remediation Verification
|
|
*
|
|
* Empirical challenger verification probing:
|
|
* 1. Multiple Shot Center Arrow Count:
|
|
* - Exhaustive sweep across arrow counts 1..24 and up to 50:
|
|
* Confirm that when arrowCount >= 2, exactly 2 central arrows are flagged
|
|
* (indices Math.floor(count/2) - 1 and Math.floor(count/2)), for both odd and even counts.
|
|
* 2. Multiple Shot Proc & Leech Suppression:
|
|
* - Verify that outer arrows with canTriggerProcs === false have Life Tap,
|
|
* life leech, mana leech, knockback, and On-Striking procs strictly suppressed in combat execution.
|
|
* 3. Strafe Ammo Consumption:
|
|
* - Verify that casting Strafe (skill ID 26) decrements the quiver ammo by exactly 1 arrow per cast,
|
|
* and does not decrement per individual missile in the volley or drop below 0.
|
|
*
|
|
* Ground Truth:
|
|
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt
|
|
* - D2MOO C++ reference: SkillAma.cpp (515-560, 253), MissMode.cpp (4748), SUnitDmg.cpp (1135)
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
generateMultipleShotAngles,
|
|
isMultipleShotCenterArrow,
|
|
calculateMultipleShotStats,
|
|
calculateStrafeStats,
|
|
} from '../../../src/game/skills/amazon-bow.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,
|
|
type CombatUnitContext,
|
|
type SUnitDmgPacket,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { AnimDispatcher } from '../../../src/game/engine/anim-dispatcher.ts'
|
|
|
|
function createCombatTarget(
|
|
id: string,
|
|
pos: { x: number; y: number },
|
|
registry: any,
|
|
opts?: {
|
|
hp?: number
|
|
maxHp?: number
|
|
mana?: number
|
|
maxMana?: number
|
|
isMoving?: boolean
|
|
def?: number
|
|
weaponMin?: number
|
|
weaponMax?: number
|
|
}
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList(registry, {
|
|
level: 80,
|
|
hitpoints: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
maxhp: (opts?.maxHp ?? opts?.hp ?? 10000) * FIXED_ONE,
|
|
mana: (opts?.mana ?? 500) * FIXED_ONE,
|
|
maxmana: (opts?.maxMana ?? opts?.mana ?? 500) * FIXED_ONE,
|
|
armorclass: opts?.def ?? 400,
|
|
fireresist: 0,
|
|
coldresist: 0,
|
|
lightresist: 0,
|
|
poisonresist: 0,
|
|
damageresist: 0,
|
|
magicresist: 0,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
})
|
|
const stateBus = new StateBus(statList, registry)
|
|
return {
|
|
id,
|
|
name: id,
|
|
statList,
|
|
stateBus,
|
|
isMoving: opts?.isMoving ?? false,
|
|
...(opts?.weaponMin !== undefined ? { weaponMinPhys: opts.weaponMin } : {}),
|
|
...(opts?.weaponMax !== undefined ? { weaponMaxPhys: opts.weaponMax } : {}),
|
|
x: pos.x,
|
|
y: pos.y,
|
|
}
|
|
}
|
|
|
|
describe('Adversarial Challenger Verification Suite: Amazon Iteration 2 Remediations', () => {
|
|
// =========================================================================
|
|
// 1. Multiple Shot Center Arrow Count Verification
|
|
// =========================================================================
|
|
describe('1. Multiple Shot Center Arrow Count (1.13c Ground Truth: exactly 2 central arrows for count >= 2)', () => {
|
|
it('verifies count = 1 edge case flags exactly 1 arrow at index 0', () => {
|
|
expect(isMultipleShotCenterArrow(0, 1)).toBe(true)
|
|
expect(isMultipleShotCenterArrow(1, 1)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(-1, 1)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(99, 1)).toBe(false)
|
|
})
|
|
|
|
it('exhaustively sweeps arrow counts 1..24 and verifies exactly 2 central arrows flagged for count >= 2', () => {
|
|
// Differential Oracle
|
|
function oracleCenterIndices(count: number): number[] {
|
|
if (count <= 0) return []
|
|
if (count === 1) return [0]
|
|
const mid2 = Math.floor(count / 2)
|
|
const mid1 = mid2 - 1
|
|
return [mid1, mid2]
|
|
}
|
|
|
|
for (let count = 1; count <= 24; count++) {
|
|
const flaggedIndices: number[] = []
|
|
for (let idx = 0; idx < count; idx++) {
|
|
if (isMultipleShotCenterArrow(idx, count)) {
|
|
flaggedIndices.push(idx)
|
|
}
|
|
}
|
|
|
|
const expected = oracleCenterIndices(count)
|
|
expect(flaggedIndices).toEqual(expected)
|
|
|
|
if (count >= 2) {
|
|
// Invariant: Exactly 2 arrows flagged
|
|
expect(flaggedIndices.length).toBe(2)
|
|
|
|
const mid2 = Math.floor(count / 2)
|
|
const mid1 = mid2 - 1
|
|
expect(flaggedIndices[0]).toBe(mid1)
|
|
expect(flaggedIndices[1]).toBe(mid2)
|
|
// Ensure both indices are strictly distinct and within bounds
|
|
expect(mid1).toBeGreaterThanOrEqual(0)
|
|
expect(mid2).toBeLessThan(count)
|
|
expect(mid1).toBeLessThan(mid2)
|
|
} else {
|
|
expect(flaggedIndices.length).toBe(1)
|
|
expect(flaggedIndices[0]).toBe(0)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('verifies odd vs even counts explicitly across representative slvls', () => {
|
|
// slvl 1: arrowCount = 2 + 1 = 3 (odd) -> indices [0, 1]
|
|
const odd3 = [0, 1, 2].filter(i => isMultipleShotCenterArrow(i, 3))
|
|
expect(odd3).toEqual([0, 1])
|
|
|
|
// slvl 2: arrowCount = 2 + 2 = 4 (even) -> indices [1, 2]
|
|
const even4 = [0, 1, 2, 3].filter(i => isMultipleShotCenterArrow(i, 4))
|
|
expect(even4).toEqual([1, 2])
|
|
|
|
// slvl 3: arrowCount = 2 + 3 = 5 (odd) -> indices [1, 2]
|
|
const odd5 = [0, 1, 2, 3, 4].filter(i => isMultipleShotCenterArrow(i, 5))
|
|
expect(odd5).toEqual([1, 2])
|
|
|
|
// slvl 5: arrowCount = 7 (odd) -> indices [2, 3]
|
|
const odd7 = [0, 1, 2, 3, 4, 5, 6].filter(i => isMultipleShotCenterArrow(i, 7))
|
|
expect(odd7).toEqual([2, 3])
|
|
|
|
// slvl 6: arrowCount = 8 (even) -> indices [3, 4]
|
|
const even8 = [0, 1, 2, 3, 4, 5, 6, 7].filter(i => isMultipleShotCenterArrow(i, 8))
|
|
expect(even8).toEqual([3, 4])
|
|
|
|
// slvl 9: arrowCount = 11 (odd) -> indices [4, 5]
|
|
const odd11 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter(i => isMultipleShotCenterArrow(i, 11))
|
|
expect(odd11).toEqual([4, 5])
|
|
|
|
// slvl 20: arrowCount = 22 (even) -> indices [10, 11]
|
|
const even22 = Array.from({ length: 22 }, (_, i) => i).filter(i => isMultipleShotCenterArrow(i, 22))
|
|
expect(even22).toEqual([10, 11])
|
|
|
|
// slvl 22: arrowCount = 24 (even, standard max volley) -> indices [11, 12]
|
|
const even24 = Array.from({ length: 24 }, (_, i) => i).filter(i => isMultipleShotCenterArrow(i, 24))
|
|
expect(even24).toEqual([11, 12])
|
|
})
|
|
|
|
it('verifies boundary and degenerate inputs', () => {
|
|
// Degenerate non-positive counts
|
|
expect(isMultipleShotCenterArrow(0, 0)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(0, -5)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(-1, 5)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(5, 5)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(6, 5)).toBe(false)
|
|
|
|
// High arrow counts up to 50
|
|
for (let count = 25; count <= 50; count++) {
|
|
const flagged = Array.from({ length: count }, (_, i) => i).filter(i =>
|
|
isMultipleShotCenterArrow(i, count)
|
|
)
|
|
expect(flagged.length).toBe(2)
|
|
expect(flagged[0]).toBe(Math.floor(count / 2) - 1)
|
|
expect(flagged[1]).toBe(Math.floor(count / 2))
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 2. Multiple Shot Proc & Leech Suppression
|
|
// =========================================================================
|
|
describe('2. Multiple Shot Proc & Leech Suppression (1.13c Ground Truth: canTriggerProcs === false)', () => {
|
|
it('verifies spawnMultipleShotVolley sets canTriggerProcs appropriately on center and outer missiles', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createCombatTarget('player-ama', { x: 0, y: 0 }, registry)
|
|
|
|
// slvl 3 -> 5 arrows (indices 0, 1, 2, 3, 4). Center indices are 1 and 2.
|
|
const missiles = missileEngine.spawnMultipleShotVolley({
|
|
sourceSkillId: 12,
|
|
slvl: 3,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 200,
|
|
targetY: 0,
|
|
dmgPacket: {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
knockback: true,
|
|
},
|
|
})
|
|
|
|
expect(missiles.length).toBe(5)
|
|
// Check each missile's proc flag against center calculation
|
|
for (let i = 0; i < missiles.length; i++) {
|
|
const expectedCenter = isMultipleShotCenterArrow(i, 5)
|
|
expect(missiles[i]!.canTriggerProcs).toBe(expectedCenter)
|
|
expect(missiles[i]!.dmgPacket.canTriggerProcs).toBe(expectedCenter)
|
|
}
|
|
})
|
|
|
|
it('verifies combat pipeline strictly suppresses Life Tap healing when canTriggerProcs === false', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
// Attacker has initial 500 HP out of 2000 Max HP (allowing headroom for healing)
|
|
const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry, {
|
|
hp: 500,
|
|
maxHp: 2000,
|
|
weaponMin: 0,
|
|
weaponMax: 0,
|
|
})
|
|
const target = createCombatTarget('defender', { x: 50, y: 0 }, registry, { hp: 1000 })
|
|
|
|
// Apply Life Tap to defender (50% physical damage healed to attacker)
|
|
target.stateBus.applyState({
|
|
stateNameOrId: 'lifetap',
|
|
slvl: 1,
|
|
durationFrames: 600,
|
|
})
|
|
|
|
const baseHp = owner.statList.getHp256()
|
|
|
|
// Outer arrow: canTriggerProcs === false
|
|
const outerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
canTriggerProcs: false,
|
|
}
|
|
|
|
const resOuter = executeSUnitDmg(owner, target, outerPacket)
|
|
expect(resOuter.hit).toBe(true)
|
|
expect(resOuter.procsSuppressed).toBe(true)
|
|
expect(resOuter.attackerHealed256).toBeUndefined()
|
|
expect(owner.statList.getHp256()).toBe(baseHp) // Zero healing
|
|
|
|
// Center arrow: canTriggerProcs === true
|
|
const centerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
canTriggerProcs: true,
|
|
}
|
|
|
|
const resCenter = executeSUnitDmg(owner, target, centerPacket)
|
|
expect(resCenter.hit).toBe(true)
|
|
expect(resCenter.procsSuppressed).toBeUndefined()
|
|
const expectedHeal = Math.trunc((resCenter.physDamage256 * 50) / 100)
|
|
expect(resCenter.attackerHealed256).toBe(expectedHeal)
|
|
expect(owner.statList.getHp256()).toBe(baseHp + expectedHeal)
|
|
})
|
|
|
|
it('verifies combat pipeline strictly suppresses Life Leech and Mana Leech when canTriggerProcs === false', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
// Attacker has initial 400 HP / 2000 Max HP, 200 Mana / 1000 Max Mana (headroom for leech)
|
|
const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry, {
|
|
hp: 400,
|
|
maxHp: 2000,
|
|
mana: 200,
|
|
maxMana: 1000,
|
|
weaponMin: 0,
|
|
weaponMax: 0,
|
|
})
|
|
const target = createCombatTarget('defender', { x: 50, y: 0 }, registry, { hp: 2000 })
|
|
|
|
// Grant attacker 15% Life Leech and 10% Mana Leech
|
|
owner.statList.addStat('item_parasite', 15) // life leech
|
|
owner.statList.addStat('item_parasitemana', 10) // mana leech
|
|
|
|
const hpBefore = owner.statList.getHp256()
|
|
const manaBefore = owner.statList.getMana256()
|
|
|
|
// Outer arrow: canTriggerProcs === false
|
|
const outerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 200 * FIXED_ONE,
|
|
flatPhysMax256: 200 * FIXED_ONE,
|
|
autoHit: true,
|
|
canTriggerProcs: false,
|
|
}
|
|
|
|
const resOuter = executeSUnitDmg(owner, target, outerPacket)
|
|
expect(resOuter.hit).toBe(true)
|
|
expect(resOuter.procsSuppressed).toBe(true)
|
|
expect(resOuter.attackerHealed256).toBeUndefined()
|
|
expect(resOuter.attackerManaLeeched256).toBeUndefined()
|
|
expect(owner.statList.getHp256()).toBe(hpBefore)
|
|
expect(owner.statList.getMana256()).toBe(manaBefore)
|
|
|
|
// Center arrow: canTriggerProcs === true
|
|
const centerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 200 * FIXED_ONE,
|
|
flatPhysMax256: 200 * FIXED_ONE,
|
|
autoHit: true,
|
|
canTriggerProcs: true,
|
|
}
|
|
|
|
const resCenter = executeSUnitDmg(owner, target, centerPacket)
|
|
expect(resCenter.hit).toBe(true)
|
|
expect(resCenter.procsSuppressed).toBeUndefined()
|
|
const expectedLifeLeech = Math.trunc((resCenter.physDamage256 * 15) / 100)
|
|
const expectedManaLeech = Math.trunc((resCenter.physDamage256 * 10) / 100)
|
|
expect(resCenter.attackerHealed256).toBe(expectedLifeLeech)
|
|
expect(resCenter.attackerManaLeeched256).toBe(expectedManaLeech)
|
|
expect(owner.statList.getHp256()).toBe(hpBefore + expectedLifeLeech)
|
|
expect(owner.statList.getMana256()).toBe(manaBefore + expectedManaLeech)
|
|
})
|
|
|
|
it('verifies combat pipeline strictly suppresses Knockback when canTriggerProcs === false', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry)
|
|
const target = createCombatTarget('defender', { x: 50, y: 0 }, registry)
|
|
|
|
expect(target.stateBus.hasState('knockback')).toBe(false)
|
|
|
|
// Outer arrow with knockback: true but canTriggerProcs: false
|
|
const outerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
knockback: true,
|
|
canTriggerProcs: false,
|
|
}
|
|
|
|
const resOuter = executeSUnitDmg(owner, target, outerPacket)
|
|
expect(resOuter.hit).toBe(true)
|
|
expect(target.stateBus.hasState('knockback')).toBe(false) // Knockback suppressed!
|
|
|
|
// Center arrow with knockback: true and canTriggerProcs: true
|
|
const centerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
knockback: true,
|
|
canTriggerProcs: true,
|
|
}
|
|
|
|
const resCenter = executeSUnitDmg(owner, target, centerPacket)
|
|
expect(resCenter.hit).toBe(true)
|
|
expect(target.stateBus.hasState('knockback')).toBe(true) // Knockback applied!
|
|
})
|
|
|
|
it('verifies outer arrows still deal full damage even while procs and leech are suppressed', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry)
|
|
const target = createCombatTarget('defender', { x: 50, y: 0 }, registry, { hp: 1000 })
|
|
|
|
const hpBefore = target.statList.getHp256()
|
|
|
|
const outerPacket: SUnitDmgPacket = {
|
|
skillId: 12,
|
|
attackKind: 'missile',
|
|
flatPhysMin256: 120 * FIXED_ONE,
|
|
flatPhysMax256: 120 * FIXED_ONE,
|
|
autoHit: true,
|
|
canTriggerProcs: false,
|
|
}
|
|
|
|
const resOuter = executeSUnitDmg(owner, target, outerPacket)
|
|
expect(resOuter.hit).toBe(true)
|
|
expect(resOuter.totalDamage256).toBe(resOuter.physDamage256)
|
|
expect(target.statList.getHp256()).toBe(hpBefore - resOuter.totalDamage256)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 3. Strafe Ammo Consumption
|
|
// =========================================================================
|
|
describe('3. Strafe Ammo Consumption (1.13c Ground Truth: 1 arrow decremented per cast)', () => {
|
|
it('verifies AnimDispatcher decrements ammo by exactly 1 per Strafe cast', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const statList = new UnitStatList(registry, {
|
|
level: 80,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxhp: 1000 * FIXED_ONE,
|
|
mana: 500 * FIXED_ONE,
|
|
maxmana: 500 * FIXED_ONE,
|
|
})
|
|
const stateBus = new StateBus(statList, registry)
|
|
const dispatcher = new AnimDispatcher({
|
|
registry,
|
|
charToken: 'AM',
|
|
statList,
|
|
stateBus,
|
|
weaponClass: 'BOW',
|
|
})
|
|
|
|
// Set initial quiver ammo
|
|
dispatcher.setAmmoQuantity(250)
|
|
expect(dispatcher.getAmmoQuantity()).toBe(250)
|
|
|
|
const skillStrafe = registry.getSkillById(26)!
|
|
|
|
// Cast 1
|
|
const cast1 = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 10,
|
|
currentTick: 0,
|
|
})
|
|
expect(cast1.allowed).toBe(true)
|
|
const anim1 = cast1.animState!
|
|
for (let t = 0; t <= anim1.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(249) // Exactly 1 arrow consumed
|
|
|
|
// Cast 2
|
|
const cast2 = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 10,
|
|
currentTick: 20,
|
|
})
|
|
expect(cast2.allowed).toBe(true)
|
|
const anim2 = cast2.animState!
|
|
for (let t = 20; t <= 20 + anim2.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(248) // Exactly 1 more arrow consumed
|
|
|
|
// Cast 10 consecutive times
|
|
for (let castIdx = 0; castIdx < 10; castIdx++) {
|
|
const tickBase = 50 + castIdx * 30
|
|
const c = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 20,
|
|
currentTick: tickBase,
|
|
})
|
|
expect(c.allowed).toBe(true)
|
|
for (let t = tickBase; t <= tickBase + c.animState!.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
}
|
|
// 248 - 10 = 238
|
|
expect(dispatcher.getAmmoQuantity()).toBe(238)
|
|
})
|
|
|
|
it('verifies Strafe ammo consumption does not reduce ammo below 0', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const statList = new UnitStatList(registry, {
|
|
level: 80,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxhp: 1000 * FIXED_ONE,
|
|
})
|
|
const stateBus = new StateBus(statList, registry)
|
|
const dispatcher = new AnimDispatcher({
|
|
registry,
|
|
charToken: 'AM',
|
|
statList,
|
|
stateBus,
|
|
weaponClass: 'BOW',
|
|
})
|
|
|
|
// Ammo starts at 1
|
|
dispatcher.setAmmoQuantity(1)
|
|
|
|
const skillStrafe = registry.getSkillById(26)!
|
|
|
|
// Cast when ammo is 1 -> reaches 0
|
|
const c1 = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 1,
|
|
currentTick: 0,
|
|
})
|
|
expect(c1.allowed).toBe(true)
|
|
for (let t = 0; t <= c1.animState!.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(0)
|
|
|
|
// Cast when ammo is 0 -> should not go negative (-1)
|
|
const c2 = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 1,
|
|
currentTick: 20,
|
|
})
|
|
expect(c2.allowed).toBe(true)
|
|
for (let t = 20; t <= 20 + c2.animState!.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(0)
|
|
})
|
|
|
|
it('verifies contrast with Multiple Shot (consumes 1) and Magic Arrow (consumes 0)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const statList = new UnitStatList(registry, {
|
|
level: 80,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
maxhp: 1000 * FIXED_ONE,
|
|
})
|
|
const stateBus = new StateBus(statList, registry)
|
|
const dispatcher = new AnimDispatcher({
|
|
registry,
|
|
charToken: 'AM',
|
|
statList,
|
|
stateBus,
|
|
weaponClass: 'BOW',
|
|
})
|
|
|
|
dispatcher.setAmmoQuantity(100)
|
|
|
|
const skillMultipleShot = registry.getSkillById(12)!
|
|
const skillMagicArrow = registry.getSkillById(6)!
|
|
const skillStrafe = registry.getSkillById(26)!
|
|
|
|
// Multiple Shot (skill 12, decquant: true)
|
|
const msCast = dispatcher.startSkillAnimation({
|
|
skill: skillMultipleShot,
|
|
slvl: 1,
|
|
currentTick: 0,
|
|
})
|
|
for (let t = 0; t <= msCast.animState!.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(99) // -1
|
|
|
|
// Magic Arrow (skill 6, decquant: false)
|
|
const maCast = dispatcher.startSkillAnimation({
|
|
skill: skillMagicArrow,
|
|
slvl: 1,
|
|
currentTick: 20,
|
|
})
|
|
for (let t = 20; t <= 20 + maCast.animState!.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(99) // unchanged!
|
|
|
|
// Strafe (skill 26, decquant: false in Skills.txt, but D2MOO Parity forces -1)
|
|
const strafeCast = dispatcher.startSkillAnimation({
|
|
skill: skillStrafe,
|
|
slvl: 1,
|
|
currentTick: 40,
|
|
})
|
|
for (let t = 40; t <= 40 + strafeCast.animState!.framesPerDirection + 2; t++) {
|
|
dispatcher.tick(t, () => {})
|
|
}
|
|
expect(dispatcher.getAmmoQuantity()).toBe(98) // -1
|
|
})
|
|
})
|
|
})
|