567 lines
21 KiB
TypeScript
567 lines
21 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Amazon Bow & Crossbow Kinematics Stress Suite
|
|
*
|
|
* Comprehensive adversarial and E2E kinematics integration verification:
|
|
* 1. Guided Arrow homing continuous angle steering and strict zero-pierce invariant under 100% Pierce skill.
|
|
* 2. Multiple Shot 2..24 arrow scaling, symmetrical fan arc, center 2 proc flag, and single-target hit immunity.
|
|
* 3. Strafe 4..10 arrow scaling, multi-target cycling, and stationary lock-in state.
|
|
* 4. Immolation Arrow 25-frame casting cooldown and 75-frame ground fire patch ticking.
|
|
* 5. Freezing Arrow 80 sub-pixel AoE cold splash and solid freeze application.
|
|
* 6. Magic Arrow physical-to-magic conversion and zero-ammo requirement invariant.
|
|
* 7. System invariant: BATCH1_SKILLS length strictly maintained at 38; zero mocks.
|
|
*
|
|
* Ground Truth:
|
|
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
BATCH1_SKILLS,
|
|
getSkillManaCost,
|
|
getSkillCooldownTicks,
|
|
calculateSkillDamage,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
calculateMagicArrowStats,
|
|
calculateFireArrowStats,
|
|
calculateColdArrowStats,
|
|
calculateMultipleShotStats,
|
|
calculateExplodingArrowStats,
|
|
calculateIceArrowStats,
|
|
calculateGuidedArrowStats,
|
|
calculateStrafeStats,
|
|
calculateImmolationArrowStats,
|
|
calculateFreezingArrowStats,
|
|
generateMultipleShotAngles,
|
|
isMultipleShotCenterArrow,
|
|
} 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 { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import type { CombatUnitContext, SUnitDmgPacket } from '../../../src/game/engine/combat-pipeline.ts'
|
|
|
|
function createTestCombatUnit(
|
|
id: string,
|
|
pos: { x: number; y: number },
|
|
registry: any,
|
|
opts?: { hp?: number; cannotBeFrozen?: boolean; isBoss?: boolean }
|
|
): CombatUnitContext {
|
|
const statList = new UnitStatList(registry, {
|
|
level: 80,
|
|
hitpoints: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
maxhp: (opts?.hp ?? 10000) * FIXED_ONE,
|
|
armorclass: 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,
|
|
isBoss: opts?.isBoss ?? false,
|
|
}
|
|
}
|
|
|
|
describe('Amazon Bow & Crossbow Kinematics Adversarial Stress Suite (1.13c Ground Truth)', () => {
|
|
it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow
|
|
expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow
|
|
expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow
|
|
})
|
|
|
|
describe('Guided Arrow: Homing Kinematics, Continuous Steering & Strict Zero-Pierce', () => {
|
|
it('strictly forces canPierce = false and pierceChancePct = 0 even with 100% Pierce skill', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
|
|
// Simulate 100% Pierce skill from Skill 33 or Razortail
|
|
owner.statList.setBaseSkillLevel(33, 20)
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 22,
|
|
attackKind: 'missile',
|
|
srcDam: 128,
|
|
physDamagePct: 95, // Level 20 GA: +95% ED
|
|
autoHit: true,
|
|
}
|
|
|
|
// Spawn Guided Arrow with pierceChancePct: 100 explicitly requested
|
|
const missile = missileEngine.spawnMissile({
|
|
missileNameOrId: 'guidedarrow',
|
|
sourceSkillId: 22,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 100,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
pierceChancePct: 100, // Adversarial input: 100% pierce
|
|
})
|
|
|
|
expect(missile).not.toBeNull()
|
|
// In 1.13c, Guided Arrow NEVER pierces under any circumstances
|
|
expect(missile!.canPierce).toBe(false)
|
|
expect(missile!.pierceChancePct).toBe(0)
|
|
})
|
|
|
|
it('continuously steers towards moving targets in 2:1 isometric space and auto-hits target defense', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
|
|
const targetPos = { x: 100, y: 60 }
|
|
const target = createTestCombatUnit('moving-target', targetPos, registry)
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(target.id, targetPos)
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 22,
|
|
attackKind: 'missile',
|
|
srcDam: 128,
|
|
physDamagePct: 95,
|
|
autoHit: true,
|
|
}
|
|
|
|
// Fire in opposite direction (x: -100, y: 0)
|
|
const missile = missileEngine.spawnMissile({
|
|
missileNameOrId: 'guidedarrow',
|
|
sourceSkillId: 22,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: -100,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
})!
|
|
|
|
const initialAngle = missile.angleRad
|
|
expect(initialAngle).toBeCloseTo(Math.PI)
|
|
|
|
// Step missile engine ticks and verify steering redirects towards target
|
|
for (let tick = 1; tick <= 5; tick++) {
|
|
missileEngine.tick(tick, [target], targetPositions)
|
|
}
|
|
|
|
// Missile angle should have turned away from Math.PI towards positive angle
|
|
expect(missile.angleRad).not.toBe(initialAngle)
|
|
expect(missile.expired).toBe(false)
|
|
|
|
// Now move target directly in front of current missile position and step until hit
|
|
targetPositions.set(target.id, { x: missile.x + 20, y: missile.y })
|
|
const stepRes = missileEngine.tick(6, [target], targetPositions)
|
|
|
|
expect(stepRes.hits.length).toBeGreaterThanOrEqual(1)
|
|
expect(stepRes.hits[0]!.targetId).toBe(target.id)
|
|
expect(stepRes.hits[0]!.damage).toBeGreaterThan(0)
|
|
// Missile must expire immediately on hit (zero pierce)
|
|
expect(missile.expired).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Multiple Shot: 2..24 Scaling, Fan Distribution, Center Procs & Hit Immunity', () => {
|
|
it('scales arrow count from 3 at level 1 to hard engine cap of 24 at level 22+', () => {
|
|
expect(calculateMultipleShotStats(1).arrowCount).toBe(3)
|
|
expect(calculateMultipleShotStats(5).arrowCount).toBe(7)
|
|
expect(calculateMultipleShotStats(10).arrowCount).toBe(12)
|
|
expect(calculateMultipleShotStats(20).arrowCount).toBe(22)
|
|
expect(calculateMultipleShotStats(22).arrowCount).toBe(24)
|
|
expect(calculateMultipleShotStats(30).arrowCount).toBe(24)
|
|
expect(calculateMultipleShotStats(50).arrowCount).toBe(24)
|
|
})
|
|
|
|
it('generates symmetric monotonic fan angles within 45-50 degree cone', () => {
|
|
const angles = generateMultipleShotAngles(0, 7, (50 * Math.PI) / 180)
|
|
expect(angles.length).toBe(7)
|
|
// Symmetrical about 0
|
|
expect(angles[3]).toBeCloseTo(0)
|
|
expect(angles[0]).toBeCloseTo(-angles[6]!)
|
|
expect(angles[1]).toBeCloseTo(-angles[5]!)
|
|
// Monotonic progression
|
|
for (let i = 0; i < angles.length - 1; i++) {
|
|
expect(angles[i + 1]!).toBeGreaterThan(angles[i]!)
|
|
}
|
|
})
|
|
|
|
it('flags strictly the center 2 arrows for on-striking procs; outer arrows cannot proc', () => {
|
|
// For a 6-arrow volley (indices 0, 1, 2, 3, 4, 5): center two are 2 and 3
|
|
expect(isMultipleShotCenterArrow(0, 6)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(1, 6)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(2, 6)).toBe(true)
|
|
expect(isMultipleShotCenterArrow(3, 6)).toBe(true)
|
|
expect(isMultipleShotCenterArrow(4, 6)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(5, 6)).toBe(false)
|
|
|
|
// For a 24-arrow volley: center two are 11 and 12
|
|
expect(isMultipleShotCenterArrow(11, 24)).toBe(true)
|
|
expect(isMultipleShotCenterArrow(12, 24)).toBe(true)
|
|
expect(isMultipleShotCenterArrow(10, 24)).toBe(false)
|
|
expect(isMultipleShotCenterArrow(13, 24)).toBe(false)
|
|
})
|
|
|
|
it('enforces single-target hit immunity per Multiple Shot volley (no multi-hit shotgunning)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
|
|
const target = createTestCombatUnit('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, // 75% weapon penalty
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
}
|
|
|
|
// Spawn a 24-arrow volley where multiple arrows pass through the target point
|
|
const arrows = missileEngine.spawnMultipleShotVolley({
|
|
sourceSkillId: 12,
|
|
slvl: 24,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 80,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
})
|
|
|
|
expect(arrows.length).toBe(24)
|
|
const volleyId = arrows[0]!.volleyId
|
|
expect(volleyId).toBeDefined()
|
|
for (const a of arrows) {
|
|
expect(a.volleyId).toBe(volleyId)
|
|
}
|
|
|
|
// Step missile engine until arrows reach target at x=80
|
|
let totalHitsOnTarget = 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) {
|
|
totalHitsOnTarget += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Crucial 1.13c Invariant: Despite 24 arrows in the volley, a single monster receives damage exactly ONCE!
|
|
expect(totalHitsOnTarget).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Strafe: Target Cycling, Rapid Burst Cadence & Stationary Lock-in', () => {
|
|
it('scales arrow count min(10, 4 + floor(slvl/2)) and applies 75% weapon damage + %ED', () => {
|
|
const s1 = calculateStrafeStats(1)
|
|
expect(s1.arrowCount).toBe(4)
|
|
expect(s1.damageMultiplier).toBe(0.75)
|
|
expect(s1.srcDam).toBe(96)
|
|
expect(s1.enhancedDamagePct).toBe(0)
|
|
expect(s1.manaCost).toBe(11)
|
|
|
|
const s2 = calculateStrafeStats(2)
|
|
expect(s2.arrowCount).toBe(5)
|
|
|
|
const s10 = calculateStrafeStats(10)
|
|
expect(s10.arrowCount).toBe(9)
|
|
expect(s10.enhancedDamagePct).toBe(45)
|
|
|
|
const s12 = calculateStrafeStats(12)
|
|
expect(s12.arrowCount).toBe(10)
|
|
|
|
const s20 = calculateStrafeStats(20)
|
|
expect(s20.arrowCount).toBe(10)
|
|
expect(s20.enhancedDamagePct).toBe(95)
|
|
})
|
|
|
|
it('distributes shots sequentially across multiple targets and applies stationary lock-in state', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
|
|
const t1 = createTestCombatUnit('enemy-1', { x: 100, y: 0 }, registry)
|
|
const t2 = createTestCombatUnit('enemy-2', { x: 0, y: 100 }, registry)
|
|
const t3 = createTestCombatUnit('enemy-3', { 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)
|
|
// Player is locked in stationary state
|
|
expect(owner.stateBus.hasState('strafe_lockin')).toBe(true)
|
|
const lockInState = owner.stateBus.getState('strafe_lockin')
|
|
expect(lockInState?.durationFrames).toBe(24) // 10 * 2 + 4
|
|
|
|
// Verify arrows target different positions cyclically
|
|
expect(arrows[0]!.angleRad).not.toBe(arrows[1]!.angleRad)
|
|
expect(arrows[1]!.angleRad).not.toBe(arrows[2]!.angleRad)
|
|
})
|
|
})
|
|
|
|
describe('Immolation Arrow: Casting Cooldown (25 frames) & Lingering Fire Patch (75 frames)', () => {
|
|
it('returns exact 25-frame casting cooldown and 75-frame ground fire duration', () => {
|
|
expect(getSkillCooldownTicks(27)).toBe(25) // 1.0 second delay in 1.13c
|
|
const stats = calculateImmolationArrowStats(20, { fireArrow: 20 })
|
|
expect(stats.cooldownFrames).toBe(25)
|
|
expect(stats.firePatchDurationFrames).toBe(75) // 3.0 seconds
|
|
expect(stats.explosionSynergyMultiplier).toBe(2.0)
|
|
expect(stats.firePatchSynergyMultiplier).toBe(3.0)
|
|
})
|
|
|
|
it('persists ground fire patch across 75 frames and ticks damage at 5-frame intervals', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
|
|
const target = createTestCombatUnit('standing-monster', { x: 50, y: 0 }, registry)
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(target.id, { x: 50, y: 0 })
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 27,
|
|
attackKind: 'missile',
|
|
elemType: 'fire',
|
|
elemMin256: 50 * FIXED_ONE,
|
|
elemMax256: 50 * FIXED_ONE,
|
|
autoHit: true,
|
|
}
|
|
|
|
// Spawn primary immolation arrow
|
|
const mainArrow = missileEngine.spawnMissile({
|
|
missileNameOrId: 'immolationarrow',
|
|
sourceSkillId: 27,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 50,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
})!
|
|
|
|
// Step until impact
|
|
let firePatchSpawned = false
|
|
for (let tick = 1; tick <= 5; tick++) {
|
|
const step = missileEngine.tick(tick, [target], targetPositions)
|
|
if (step.subMissilesSpawned.includes('immolationfire')) {
|
|
firePatchSpawned = true
|
|
}
|
|
}
|
|
|
|
expect(firePatchSpawned).toBe(true)
|
|
|
|
// Find the spawned immolationfire patch missile
|
|
const activeMissiles = missileEngine.getActiveMissiles()
|
|
const patch = activeMissiles.find(m => m.name === 'immolationfire')
|
|
expect(patch).toBeDefined()
|
|
expect(patch!.maxRangeTicks).toBe(75)
|
|
|
|
// Step through 20 ticks and count ticking damage
|
|
let tickHits = 0
|
|
for (let tick = 6; tick <= 25; tick++) {
|
|
const step = missileEngine.tick(tick, [target], targetPositions)
|
|
for (const h of step.hits) {
|
|
if (h.missileName === 'immolationfire') {
|
|
tickHits += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ground patch ticks every 5 ticks -> 20 ticks / 5 = 4 ticks
|
|
expect(tickHits).toBeGreaterThanOrEqual(3)
|
|
expect(patch!.expired).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Freezing Arrow: 80 Sub-pixel Cold Splash & AoE Solid Freeze', () => {
|
|
it('splashes cold damage and applies freeze state to all enemies within 80 sub-pixels', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const missileEngine = new MissileEngine(registry)
|
|
|
|
const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry)
|
|
const primary = createTestCombatUnit('primary-enemy', { x: 60, y: 0 }, registry)
|
|
const close1 = createTestCombatUnit('splash-enemy-1', { x: 80, y: 20 }, registry) // dist ~28px <= 80
|
|
const close2 = createTestCombatUnit('splash-enemy-2', { x: 40, y: -20 }, registry) // dist ~28px <= 80
|
|
const farEnemy = createTestCombatUnit('far-enemy', { x: 200, y: 0 }, registry) // dist 140px > 80
|
|
|
|
const allTargets = [primary, close1, close2, farEnemy]
|
|
const targetPositions = new Map<string, { x: number; y: number }>()
|
|
targetPositions.set(primary.id, { x: 60, y: 0 })
|
|
targetPositions.set(close1.id, { x: 80, y: 20 })
|
|
targetPositions.set(close2.id, { x: 40, y: -20 })
|
|
targetPositions.set(farEnemy.id, { x: 200, y: 0 })
|
|
|
|
const dmgPacket: SUnitDmgPacket = {
|
|
skillId: 31,
|
|
attackKind: 'missile',
|
|
elemType: 'cold',
|
|
elemMin256: 300 * FIXED_ONE,
|
|
elemMax256: 300 * FIXED_ONE,
|
|
autoHit: true,
|
|
}
|
|
|
|
// Spawn Freezing Arrow towards primary
|
|
missileEngine.spawnMissile({
|
|
missileNameOrId: 'freezingarrow',
|
|
sourceSkillId: 31,
|
|
slvl: 20,
|
|
owner,
|
|
startX: 0,
|
|
startY: 0,
|
|
targetX: 60,
|
|
targetY: 0,
|
|
dmgPacket,
|
|
})
|
|
|
|
// Step until impact and splash detonation
|
|
for (let tick = 1; tick <= 5; tick++) {
|
|
missileEngine.tick(tick, allTargets, targetPositions)
|
|
}
|
|
|
|
// Primary and close splash targets must receive freeze state
|
|
expect(primary.stateBus.hasState('freeze')).toBe(true)
|
|
expect(close1.stateBus.hasState('freeze')).toBe(true)
|
|
expect(close2.stateBus.hasState('freeze')).toBe(true)
|
|
|
|
// Far enemy outside 80 sub-pixels must NOT be frozen or damaged
|
|
expect(farEnemy.stateBus.hasState('freeze')).toBe(false)
|
|
expect(farEnemy.statList.getHp256()).toBe(farEnemy.statList.getMaxHp256())
|
|
})
|
|
})
|
|
|
|
describe('Integration with WorldArena & Central Skill Dispatchers', () => {
|
|
it('correctly maps calculateSkillDamage, getSkillManaCost, and getSkillCooldownTicks across all 10 bow skills', () => {
|
|
// Skill 6: Magic Arrow
|
|
expect(getSkillManaCost(6, 1)).toBe(1.5)
|
|
expect(getSkillManaCost(6, 13)).toBe(0.0)
|
|
expect(getSkillCooldownTicks(6)).toBe(0)
|
|
const dmg6 = calculateSkillDamage(6, 1, 1.0, { min: 20, max: 30 })
|
|
expect(dmg6.min).toBe(21) // 20 + 1
|
|
expect(dmg6.max).toBe(31) // 30 + 1
|
|
|
|
// Skill 7: Fire Arrow
|
|
expect(getSkillManaCost(7, 1)).toBe(3.0)
|
|
expect(getSkillCooldownTicks(7)).toBe(0)
|
|
const dmg7 = calculateSkillDamage(7, 1)
|
|
expect(dmg7.min).toBe(1)
|
|
expect(dmg7.max).toBe(4)
|
|
|
|
// Skill 11: Cold Arrow
|
|
expect(getSkillManaCost(11, 1)).toBe(3.5)
|
|
expect(getSkillCooldownTicks(11)).toBe(0)
|
|
const dmg11 = calculateSkillDamage(11, 1)
|
|
expect(dmg11.min).toBe(3)
|
|
expect(dmg11.max).toBe(4)
|
|
|
|
// Skill 12: Multiple Shot
|
|
expect(getSkillManaCost(12, 1)).toBe(4)
|
|
expect(getSkillCooldownTicks(12)).toBe(0)
|
|
const dmg12 = calculateSkillDamage(12, 1, 1.0, { min: 100, max: 200 })
|
|
expect(dmg12.min).toBe(75)
|
|
expect(dmg12.max).toBe(150)
|
|
|
|
// Skill 16: Exploding Arrow
|
|
expect(getSkillManaCost(16, 1)).toBe(5)
|
|
expect(getSkillCooldownTicks(16)).toBe(0)
|
|
const dmg16 = calculateSkillDamage(16, 1)
|
|
expect(dmg16.min).toBe(2)
|
|
expect(dmg16.max).toBe(6)
|
|
|
|
// Skill 21: Ice Arrow
|
|
expect(getSkillManaCost(21, 1)).toBe(4)
|
|
expect(getSkillCooldownTicks(21)).toBe(0)
|
|
const dmg21 = calculateSkillDamage(21, 1)
|
|
expect(dmg21.min).toBe(6)
|
|
expect(dmg21.max).toBe(10)
|
|
|
|
// Skill 22: Guided Arrow
|
|
expect(getSkillManaCost(22, 1)).toBe(8)
|
|
expect(getSkillCooldownTicks(22)).toBe(0)
|
|
const dmg22 = calculateSkillDamage(22, 10, 1.0, { min: 100, max: 200 })
|
|
expect(dmg22.min).toBe(145) // +45% ED
|
|
expect(dmg22.max).toBe(290)
|
|
|
|
// Skill 26: Strafe
|
|
expect(getSkillManaCost(26, 1)).toBe(11)
|
|
expect(getSkillCooldownTicks(26)).toBe(0)
|
|
const dmg26 = calculateSkillDamage(26, 10, 1.0, { min: 100, max: 200 })
|
|
expect(dmg26.min).toBe(Math.floor(100 * 0.75 * 1.45)) // 75% * 145% ED
|
|
expect(dmg26.max).toBe(Math.floor(200 * 0.75 * 1.45))
|
|
|
|
// Skill 27: Immolation Arrow
|
|
expect(getSkillManaCost(27, 1)).toBe(6)
|
|
expect(getSkillCooldownTicks(27)).toBe(25) // Cooldown = 25 frames
|
|
const dmg27 = calculateSkillDamage(27, 1)
|
|
expect(dmg27.min).toBe(12)
|
|
expect(dmg27.max).toBe(23)
|
|
|
|
// Skill 31: Freezing Arrow
|
|
expect(getSkillManaCost(31, 1)).toBe(9)
|
|
expect(getSkillCooldownTicks(31)).toBe(0)
|
|
const dmg31 = calculateSkillDamage(31, 1)
|
|
expect(dmg31.min).toBe(40)
|
|
expect(dmg31.max).toBe(50)
|
|
})
|
|
|
|
it('triggers and steps WorldArena cast cleanly across all 10 bow skills', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const bowSkillIds = [6, 7, 11, 12, 16, 21, 22, 26, 27, 31]
|
|
|
|
for (const id of bowSkillIds) {
|
|
const arena = new WorldArena({
|
|
registry,
|
|
classCode: 'ama',
|
|
skillId: id,
|
|
slvl: 20,
|
|
})
|
|
const outcome = arena.triggerCast()
|
|
arena.stepTicks(15)
|
|
const dbg = arena.getDebugState()
|
|
|
|
expect(dbg.ready).toBe(true)
|
|
expect(dbg.skillId).toBe(id)
|
|
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
|
expect(dbg.actionFrameTriggered).toBe(true)
|
|
expect(outcome?.executed).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
})
|