diablo2-web/tests/skills/sor/adv-skill-048-042-stress.te...

603 lines
24 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
tickProjectiles,
spawnNovaRing,
calculateNovaDamage,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
getSkillManaCost,
getSkillCooldownTicks,
ISO_GROUND_ASPECT_RATIO,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
spawnNovaRing as spawnEngineNovaRing,
executeStaticField,
} from '../../../src/game/engine/missile-engine.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { executeSkillCore113c } from '../../../src/game/skills/registry.ts'
function makeUnit(registry: any, id: string, name: string, hp = 10000): CombatUnitContext {
const statList = new UnitStatList(registry, {
level: 80,
maxhp: hp * FIXED_ONE,
hitpoints: hp * FIXED_ONE,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name,
statList,
stateBus,
isDualClaw: false,
hasShield: false,
weaponMinPhys: 10,
weaponMaxPhys: 20,
}
}
describe('Empirical Challenger: Nova (48) Adversarial Stress Tests', () => {
// -------------------------------------------------------------------------
// 1. NextDelay 4 Single-Hit Protection under Overlapping Bolt Clusters
// -------------------------------------------------------------------------
describe('1. NextDelay 4 Single-Hit Protection under Overlapping Bolt Clusters', () => {
it('MissileEngine: 64 overlapping bolts at point-blank hit target exactly once across 13 frames', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const target = makeUnit(registry, 'dummy-point-blank', 'Target Dummy', 10000)
// Point blank target at (5, 0)
const targetPositions = new Map<string, { x: number; y: number }>([
['dummy-point-blank', { x: 5, y: 0 }],
])
const bolts = engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 131 * FIXED_ONE,
elemMax256: 188 * FIXED_ONE,
autoHit: true,
},
})
expect(bolts.length).toBe(64)
let totalHits = 0
const hitTicks: number[] = []
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, [target], targetPositions)
for (const h of step.hits) {
if (h.targetId === 'dummy-point-blank') {
totalHits += 1
hitTicks.push(tick)
}
}
}
// Must be hit EXACTLY once, never multiple times from a single Nova ring
expect(totalHits).toBe(1)
expect(hitTicks).toEqual([1])
})
it('MissileEngine: Large radius boss monster is hit exactly once, not multiple times by adjacent bolts', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const boss = makeUnit(registry, 'boss-duriel', 'Duriel', 50000)
// Boss at (40, 0), where multiple radial bolts spread out and pass near/through the boss
const targetPositions = new Map<string, { x: number; y: number }>([
['boss-duriel', { x: 40, y: 0 }],
])
engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 150 * FIXED_ONE,
elemMax256: 150 * FIXED_ONE,
autoHit: true,
},
})
let totalHits = 0
const hitTicks: number[] = []
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, [boss], targetPositions)
for (const h of step.hits) {
if (h.targetId === 'boss-duriel') {
totalHits += 1
hitTicks.push(tick)
}
}
}
expect(totalHits).toBe(1)
})
it('VULNERABILITY: tickProjectiles allows multi-hit across ticks if options.nextDelayByTarget is not passed (as in engine.ts:428)', () => {
// Point-blank target at (15, 0) with radius 25
const target = { index: 1, x: 15, y: 0, radius: 25, alive: true }
const bolts = spawnNovaRing(0, 0, 100)
const terrain = { overlap: () => 0 }
// Tick 1 (as invoked by src/game/engine.ts line 428: tickProjectiles(this.projectiles, targets, this.terrain))
const res1 = tickProjectiles(bolts, [target], terrain)
expect(res1.hits.length).toBe(1)
expect(res1.hits[0]!.targetIndex).toBe(1)
// Tick 2: Without nextDelayByTarget, the target takes damage AGAIN on tick 2!
const res2 = tickProjectiles(res1.alive, [target], terrain)
// Empirically confirms that if nextDelayByTarget is omitted by caller, NextDelay is violated across frames!
expect(res2.hits.length).toBe(1)
// Tick 3: bolt has moved out of collision range (48px > 25px radius + 8px missile radius)
const res3 = tickProjectiles(res2.alive, [target], terrain)
expect(res3.hits.length).toBe(0)
})
})
// -------------------------------------------------------------------------
// 2. Rapid Casting & NextDelay Lockout Window
// -------------------------------------------------------------------------
describe('2. Rapid Casting & NextDelay Lockout Window', () => {
it('MissileEngine: Second Nova cast within 4 frames of hit is locked out; cast after 4 frames hits', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const target = makeUnit(registry, 'dummy-rapid', 'Dummy Rapid', 20000)
// Target at distance 24 px (x=24, y=0)
const targetPositions = new Map<string, { x: number; y: number }>([
['dummy-rapid', { x: 24, y: 0 }],
])
const dmgPacket = {
skillId: 48,
attackKind: 'spell' as const,
elemType: 'lightning' as const,
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
}
// Cast 1 at tick 1: Nova travels 24 px at tick 1 and hits target at tick 1!
engine.spawnNovaRing({ owner, startX: 0, startY: 0, slvl: 20, dmgPacket })
const step1 = engine.tick(1, [target], targetPositions)
expect(step1.hits.length).toBe(1)
expect(step1.hits[0]!.targetId).toBe('dummy-rapid')
// Target entered NextDelay lockout at tick 1: NextDelay = 4 frames -> expires at tick 1 + 4 = 5.
// Frames 2, 3, 4 are LOCKED OUT! Frame 5: exp is 5, and exp > currentTick (5 > 5) is false, so tick 5 is allowed.
// Rapid Cast 2 at tick 2: Target is at distance 24 px, Nova 2 reaches target at tick 2!
engine.spawnNovaRing({ owner, startX: 0, startY: 0, slvl: 20, dmgPacket })
const step2 = engine.tick(2, [target], targetPositions)
// Must be locked out by NextDelay!
expect(step2.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(0)
// Tick 3: still locked out
const step3 = engine.tick(3, [target], targetPositions)
expect(step3.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(0)
// Tick 4: still locked out
const step4 = engine.tick(4, [target], targetPositions)
expect(step4.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(0)
// Cast 3 at tick 5: Nova 3 spawned at tick 5, reaches target at tick 5!
engine.spawnNovaRing({ owner, startX: 0, startY: 0, slvl: 20, dmgPacket })
const step5 = engine.tick(5, [target], targetPositions)
// Lockout expired, hit must register!
expect(step5.hits.filter(h => h.targetId === 'dummy-rapid').length).toBe(1)
})
})
// -------------------------------------------------------------------------
// 3. Multi-Monster Collision Deduping
// -------------------------------------------------------------------------
describe('3. Multi-Monster Collision Deduping', () => {
it('MissileEngine: 8 enemies encircling the player each take exactly 1 hit from the single Nova ring', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
const enemies: CombatUnitContext[] = []
const enemyPositions = new Map<string, { x: number; y: number }>()
// 8 enemies distributed at angle k * pi / 4, distance 50 px (taking into account 2:1 aspect ratio)
for (let i = 0; i < 8; i++) {
const id = `encircling-enemy-${i}`
const angle = (i / 8) * 2 * Math.PI
const x = Math.cos(angle) * 50
const y = Math.sin(angle) * 50 * ISO_GROUND_ASPECT_RATIO
enemies.push(makeUnit(registry, id, `Enemy ${i}`, 10000))
enemyPositions.set(id, { x, y })
}
engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
},
})
const hitsPerEnemy = new Map<string, number>()
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, enemies, enemyPositions)
for (const h of step.hits) {
hitsPerEnemy.set(h.targetId, (hitsPerEnemy.get(h.targetId) ?? 0) + 1)
}
}
// Every single one of the 8 enemies must be hit EXACTLY once!
expect(hitsPerEnemy.size).toBe(8)
for (let i = 0; i < 8; i++) {
expect(hitsPerEnemy.get(`encircling-enemy-${i}`)).toBe(1)
}
})
it('MissileEngine: Two enemies placed close together along the same ray (front and back)', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
// Front enemy at (24, 0), Back enemy at (72, 0)
const front = makeUnit(registry, 'front-mob', 'Front Mob', 5000)
const back = makeUnit(registry, 'back-mob', 'Back Mob', 5000)
const posMap = new Map<string, { x: number; y: number }>([
['front-mob', { x: 24, y: 0 }],
['back-mob', { x: 72, y: 0 }],
])
engine.spawnNovaRing({
owner,
startX: 0,
startY: 0,
slvl: 20,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
},
})
const hitsPerEnemy = new Map<string, number>()
for (let tick = 1; tick <= 15; tick++) {
const step = engine.tick(tick, [front, back], posMap)
for (const h of step.hits) {
hitsPerEnemy.set(h.targetId, (hitsPerEnemy.get(h.targetId) ?? 0) + 1)
}
}
// Front mob is hit by bolt directly at (24, 0)
expect(hitsPerEnemy.get('front-mob')).toBe(1)
// Since nova has 64 bolts with angle spacing 5.625 degrees, does back-mob get hit by adjacent bolt or does front absorb?
// Adjacent bolts spread out as distance increases: at x=72, angle 5.625 deg gives y = 72 * tan(5.625) = 7.1 px,
// which is well within hitRadius (28 px)!
expect(hitsPerEnemy.get('back-mob')).toBe(1)
})
})
// -------------------------------------------------------------------------
// 4. 2:1 Isometric Aspect Ratio 0.5
// -------------------------------------------------------------------------
describe('4. 2:1 Isometric Aspect Ratio 0.5', () => {
it('verifies exact 2:1 ground aspect ratio kinematics: vy is always vx * 0.5 at equal angles', () => {
const bolts = spawnNovaRing(0, 0, 50)
for (const b of bolts) {
// Vy / Vx ratio must match tan(theta) * 0.5
const theta = Math.atan2(b.vy / ISO_GROUND_ASPECT_RATIO, b.vx)
const expectedVx = Math.cos(theta) * 24
const expectedVy = Math.sin(theta) * 24 * ISO_GROUND_ASPECT_RATIO
expect(b.vx).toBeCloseTo(expectedVx, 5)
expect(b.vy).toBeCloseTo(expectedVy, 5)
}
})
it('verifies max vertical displacement is exactly half of max horizontal displacement', () => {
const bolts = spawnNovaRing(0, 0, 50)
const maxX = Math.max(...bolts.map(b => b.vx)) * 13 // ttl 13
const maxY = Math.max(...bolts.map(b => b.vy)) * 13
expect(maxX).toBeCloseTo(24 * 13, 2) // 312 px
expect(maxY).toBeCloseTo(12 * 13, 2) // 156 px
expect(maxY / maxX).toBeCloseTo(0.5, 4)
})
})
})
describe('Empirical Challenger: Static Field (42) Adversarial Stress Tests', () => {
// -------------------------------------------------------------------------
// 5. Integer Division Truncation (2 HP, 3 HP, 4 HP, 5 HP, etc.)
// -------------------------------------------------------------------------
describe('5. Integer Division Truncation', () => {
it('truncates 25% damage to 0 at 2 HP and 3 HP, never reducing HP below 1', () => {
// 1 HP: Pre-check invariant returns 0 damage immediately
const r1 = calculateStaticFieldDamage(1, 100, 'normal', 0)
expect(r1.damage).toBe(0)
expect(r1.newHp).toBe(1)
expect(r1.blockedByFloor).toBe(true)
// 2 HP: floor(2 * 0.25) = 0 damage. Target remains at 2 HP
const r2 = calculateStaticFieldDamage(2, 100, 'normal', 0)
expect(r2.damage).toBe(0)
expect(r2.newHp).toBe(2)
expect(r2.blockedByFloor).toBe(false)
// 3 HP: floor(3 * 0.25) = 0 damage. Target remains at 3 HP
const r3 = calculateStaticFieldDamage(3, 100, 'normal', 0)
expect(r3.damage).toBe(0)
expect(r3.newHp).toBe(3)
expect(r3.blockedByFloor).toBe(false)
// 4 HP: floor(4 * 0.25) = 1 damage. Target reduced to 3 HP
const r4 = calculateStaticFieldDamage(4, 100, 'normal', 0)
expect(r4.damage).toBe(1)
expect(r4.newHp).toBe(3)
expect(r4.blockedByFloor).toBe(false)
// Subsequent cast on 3 HP deals 0 damage
const r4_next = calculateStaticFieldDamage(r4.newHp, 100, 'normal', 0)
expect(r4_next.damage).toBe(0)
expect(r4_next.newHp).toBe(3)
// 5 HP: floor(5 * 0.25) = 1 damage. Target reduced to 4 HP
const r5 = calculateStaticFieldDamage(5, 100, 'normal', 0)
expect(r5.damage).toBe(1)
expect(r5.newHp).toBe(4)
// 7 HP: floor(7 * 0.25) = 1 damage. Target reduced to 6 HP
const r7 = calculateStaticFieldDamage(7, 100, 'normal', 0)
expect(r7.damage).toBe(1)
expect(r7.newHp).toBe(6)
// 8 HP: floor(8 * 0.25) = 2 damage. Target reduced to 6 HP
const r8 = calculateStaticFieldDamage(8, 100, 'normal', 0)
expect(r8.damage).toBe(2)
expect(r8.newHp).toBe(6)
})
})
// -------------------------------------------------------------------------
// 6. Difficulty Floors under Nightmare (33%) and Hell (50%)
// -------------------------------------------------------------------------
describe('6. Difficulty Floors under Nightmare (33%) and Hell (50%)', () => {
it('Nightmare difficulty: floor is floor(maxHp * 33 / 100)', () => {
const maxHp = 1000
const floor = Math.floor((maxHp * 33) / 100) // 330
// Case A: HP is at 330 (exactly at floor) -> 0 damage, blockedByFloor
const rAtFloor = calculateStaticFieldDamage(330, maxHp, 'nightmare', 0)
expect(rAtFloor.damage).toBe(0)
expect(rAtFloor.newHp).toBe(330)
expect(rAtFloor.blockedByFloor).toBe(true)
// Case B: HP is at 329 (below floor) -> 0 damage, blockedByFloor
const rBelow = calculateStaticFieldDamage(329, maxHp, 'nightmare', 0)
expect(rBelow.damage).toBe(0)
expect(rBelow.newHp).toBe(329)
expect(rBelow.blockedByFloor).toBe(true)
// Case C: HP is at 331 (above floor) -> cuts 25% of 331 = 82 -> 249 HP
const rAbove = calculateStaticFieldDamage(331, maxHp, 'nightmare', 0)
expect(rAbove.damage).toBe(82)
expect(rAbove.newHp).toBe(249)
expect(rAbove.blockedByFloor).toBe(false)
// Follow-up cast on 249 HP -> blocked by floor!
const rNext = calculateStaticFieldDamage(rAbove.newHp, maxHp, 'nightmare', 0)
expect(rNext.damage).toBe(0)
expect(rNext.newHp).toBe(249)
expect(rNext.blockedByFloor).toBe(true)
})
it('Hell difficulty: floor is floor(maxHp * 50 / 100)', () => {
const maxHp = 1000
const floor = Math.floor((maxHp * 50) / 100) // 500
// Case A: HP is at 500 (exactly at floor) -> 0 damage, blockedByFloor
const rAtFloor = calculateStaticFieldDamage(500, maxHp, 'hell', 0)
expect(rAtFloor.damage).toBe(0)
expect(rAtFloor.newHp).toBe(500)
expect(rAtFloor.blockedByFloor).toBe(true)
// Case B: HP is at 499 (below floor) -> 0 damage, blockedByFloor
const rBelow = calculateStaticFieldDamage(499, maxHp, 'hell', 0)
expect(rBelow.damage).toBe(0)
expect(rBelow.newHp).toBe(499)
expect(rBelow.blockedByFloor).toBe(true)
// Case C: HP is at 501 (above floor) -> cuts 25% of 501 = 125 -> 376 HP
const rAbove = calculateStaticFieldDamage(501, maxHp, 'hell', 0)
expect(rAbove.damage).toBe(125)
expect(rAbove.newHp).toBe(376)
expect(rAbove.blockedByFloor).toBe(false)
// Follow-up cast on 376 HP -> blocked by floor!
const rNext = calculateStaticFieldDamage(rAbove.newHp, maxHp, 'hell', 0)
expect(rNext.damage).toBe(0)
expect(rNext.newHp).toBe(376)
expect(rNext.blockedByFloor).toBe(true)
})
it('Odd Max HP values in Nightmare and Hell calculate floor correctly with integer truncation', () => {
// maxHp = 333: NM floor is floor(333 * 33 / 100) = floor(109.89) = 109
expect(calculateStaticFieldDamage(109, 333, 'nightmare', 0).blockedByFloor).toBe(true)
expect(calculateStaticFieldDamage(110, 333, 'nightmare', 0).blockedByFloor).toBe(false)
// maxHp = 777: Hell floor is floor(777 * 50 / 100) = floor(388.5) = 388
expect(calculateStaticFieldDamage(388, 777, 'hell', 0).blockedByFloor).toBe(true)
expect(calculateStaticFieldDamage(389, 777, 'hell', 0).blockedByFloor).toBe(false)
})
})
// -------------------------------------------------------------------------
// 7. Immunity Check at >= 100% Lightning Resistance
// -------------------------------------------------------------------------
describe('7. Immunity Check at >= 100% Lightning Resistance', () => {
it('returns 0 damage and immune=true for resistance 100%, 110%, 150%', () => {
for (const res of [100, 105, 110, 150, 200]) {
const out = calculateStaticFieldDamage(1000, 1000, 'normal', res)
expect(out.damage).toBe(0)
expect(out.newHp).toBe(1000)
expect(out.immune).toBe(true)
expect(out.blockedByFloor).toBe(false)
}
})
it('deals damage at resistance 99% (floor(250 * 1 / 100) = 2 damage)', () => {
const out = calculateStaticFieldDamage(1000, 1000, 'normal', 99)
expect(out.damage).toBe(2)
expect(out.newHp).toBe(998)
expect(out.immune).toBe(false)
})
it('floor pre-check takes precedence over immunity check when target is already below floor', () => {
// Hell difficulty: target has 400 HP / 1000 max HP (below 500 floor), and 110% light resist
const out = calculateStaticFieldDamage(400, 1000, 'hell', 110)
expect(out.damage).toBe(0)
expect(out.newHp).toBe(400)
expect(out.blockedByFloor).toBe(true)
expect(out.immune).toBe(false)
})
})
// -------------------------------------------------------------------------
// 8. Line-of-Sight Terrain Blocking
// -------------------------------------------------------------------------
describe('8. Line-of-Sight Terrain Blocking', () => {
it('blocks damage to targets behind solid walls across multiple ray angles', () => {
// Solid wall from x=30 to x=50, y from -100 to 100
const isBlocked = (x: number, y: number) => x >= 30 && x <= 50 && y >= -100 && y <= 100
const targets = [
{ id: 'behind-wall-1', x: 60, y: 0, currentHp: 1000, maxHp: 1000 },
{ id: 'behind-wall-2', x: 70, y: 20, currentHp: 1000, maxHp: 1000 },
{ id: 'behind-wall-3', x: 80, y: -30, currentHp: 1000, maxHp: 1000 },
{ id: 'open-field-1', x: -60, y: 0, currentHp: 1000, maxHp: 1000 },
{ id: 'open-field-2', x: 0, y: 80, currentHp: 1000, maxHp: 1000 },
]
const res = executeStaticField({
casterX: 0,
casterY: 0,
slvl: 5, // radius: 9 yards = 360 px
targets,
difficulty: 'normal',
isBlocked,
})
const behind1 = res.hits.find(h => h.targetId === 'behind-wall-1')!
const behind2 = res.hits.find(h => h.targetId === 'behind-wall-2')!
const behind3 = res.hits.find(h => h.targetId === 'behind-wall-3')!
const open1 = res.hits.find(h => h.targetId === 'open-field-1')!
const open2 = res.hits.find(h => h.targetId === 'open-field-2')!
expect(behind1.blockedByLos).toBe(true)
expect(behind1.damage).toBe(0)
expect(behind2.blockedByLos).toBe(true)
expect(behind2.damage).toBe(0)
expect(behind3.blockedByLos).toBe(true)
expect(behind3.damage).toBe(0)
expect(open1.blockedByLos).toBe(false)
expect(open1.damage).toBe(250)
expect(open2.blockedByLos).toBe(false)
expect(open2.damage).toBe(250)
})
it('hasLineOfSight correctly handles step intervals and edge-touching obstacles', () => {
// Wall between x=15 and x=17
const thinWall = (x: number) => x >= 15 && x <= 17
expect(hasLineOfSight(0, 0, 30, 0, thinWall)).toBe(false)
expect(hasLineOfSight(0, 0, 10, 0, thinWall)).toBe(true)
expect(hasLineOfSight(20, 0, 30, 0, thinWall)).toBe(true)
})
})
// -------------------------------------------------------------------------
// 9. WorldArena & executeSkillCore113c Architecture Alignment
// -------------------------------------------------------------------------
describe('9. WorldArena & executeSkillCore113c Architecture Alignment', () => {
it('audits WorldArena behavior for Static Field (Skill 42)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 42,
slvl: 20,
})
const dummyNormal = arena.targets.find(t => t.id === 'dummy-normal')!
const initialHp = dummyNormal.statList.getHp()
expect(initialHp).toBe(10000)
arena.triggerCast()
arena.stepTicks(15)
const finalHp = dummyNormal.statList.getHp()
expect(finalHp).toBe(7500) // Cut by 25% via combat-pipeline executeSUnitDmg
// However, combat-pipeline executeSUnitDmg lacks difficulty floors and LOS checking!
})
it('audits WorldArena behavior for Nova (Skill 48) — verifies 64-bolt ring and deferred missile damage', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 48,
slvl: 20,
})
const outcome = arena.triggerCast()
// executeSkillCore113c spawns authentic 64-bolt circular ring
expect(outcome?.missilesSpawned.length).toBe(64)
expect(outcome?.missilesSpawned[0]).toBe('nova')
// Frame 0 cast deals 0 direct damage; damage is delivered as missiles travel
expect(outcome?.totalDamageDealt).toBe(0)
// Step simulation for 15 frames: Nova bolts expand and strike dummies
arena.stepTicks(15)
const dummyNormal = arena.targets.find(t => t.id === 'dummy-normal')!
expect(dummyNormal.statList.getHp()).toBeLessThan(10000)
})
})
})