507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Blizzard (Skill 59) Empirical Challenger Suite
|
|
*
|
|
* EMPIRICAL CHALLENGER VERIFICATION (solution-stress-testing playbook):
|
|
* 1. 5-band damage progression across slvl 1 to 50 matches 1.13c formula exactly
|
|
* (base 45-75, bands [15/16, 30/31, 45/46, 55/56, 65/66]).
|
|
* 2. Synergy multiplier matches +5% per hard point in Ice Bolt, Ice Blast, Glacial Spike.
|
|
* 3. Authoritative 64-entry circle table values in `src/game/engine/blizzard-table.ts`
|
|
* match D2Game.dll 0x6FD1C960 and 0x6FD1CA60 with 0 errors.
|
|
* 4. Exactly 25 shards dropped over 100 ticks (cadence 4, step 13 mod 64).
|
|
* 5. 2:1 isometric aspect ratio scaling on ground plane (`dy = tblY * scale * 0.5`).
|
|
* 6. Altitude descent (120 px to 0 over 9 ticks) and ground detonation.
|
|
* 7. NextDelay = 0 sweet-spot burst collision.
|
|
* 8. High-density performance stress test (100 concurrent Blizzard centers = 2,500 shards).
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import {
|
|
calculateBlizzardDamage,
|
|
calculateBlizzardSynergyMultiplier,
|
|
tickProjectiles,
|
|
ISO_GROUND_ASPECT_RATIO,
|
|
type Projectile,
|
|
} from '../src/game/skills.ts'
|
|
import {
|
|
BLIZZARD_CIRCLE_X,
|
|
BLIZZARD_CIRCLE_Y,
|
|
getBlizzardDropOffset,
|
|
SUBTILE_TO_PX,
|
|
} from '../src/game/engine/blizzard-table.ts'
|
|
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
|
import { UnitStatList } from '../src/game/engine/stat-list.ts'
|
|
import { skillModule } from '../src/game/skills/impl/sor/skill-059-blizzard.ts'
|
|
|
|
/**
|
|
* Independent mathematical oracle for 1.13c Blizzard 5-band damage.
|
|
* Derived directly from D2Common.dll disassembly VA 0x6FD9DDB0 & 0x6FDBA580:
|
|
* Base: 45 - 75
|
|
* Band 1 (slvl 2..8): +15 / +16 per slvl (7 levels)
|
|
* Band 2 (slvl 9..16): +30 / +31 per slvl (8 levels)
|
|
* Band 3 (slvl 17..22): +45 / +46 per slvl (6 levels)
|
|
* Band 4 (slvl 23..28): +55 / +56 per slvl (6 levels)
|
|
* Band 5 (slvl 29+): +65 / +66 per slvl
|
|
*/
|
|
function oracle113cBlizzardDamage(
|
|
slvl: number,
|
|
synergies: { iceBolt?: number; iceBlast?: number; glacialSpike?: number } = {},
|
|
): { min: number; max: number } {
|
|
const lvl = Math.max(1, Math.floor(slvl))
|
|
let min = 45
|
|
let max = 75
|
|
|
|
if (lvl > 1) {
|
|
const b1 = Math.min(lvl, 8) - 1
|
|
min += b1 * 15
|
|
max += b1 * 16
|
|
}
|
|
if (lvl > 8) {
|
|
const b2 = Math.min(lvl, 16) - 8
|
|
min += b2 * 30
|
|
max += b2 * 31
|
|
}
|
|
if (lvl > 16) {
|
|
const b3 = Math.min(lvl, 22) - 16
|
|
min += b3 * 45
|
|
max += b3 * 46
|
|
}
|
|
if (lvl > 22) {
|
|
const b4 = Math.min(lvl, 28) - 22
|
|
min += b4 * 55
|
|
max += b4 * 56
|
|
}
|
|
if (lvl > 28) {
|
|
const b5 = lvl - 28
|
|
min += b5 * 65
|
|
max += b5 * 66
|
|
}
|
|
|
|
const hardPoints =
|
|
(synergies.iceBolt ?? 0) + (synergies.iceBlast ?? 0) + (synergies.glacialSpike ?? 0)
|
|
const synMultiplier = 1.0 + hardPoints * 0.05
|
|
|
|
return {
|
|
min: Math.floor(min * synMultiplier),
|
|
max: Math.floor(max * synMultiplier),
|
|
}
|
|
}
|
|
|
|
describe('[Skill #059 Challenger] Empirical Stress Testing & Oracle Verification Suite', () => {
|
|
it('1. 5-band damage progression across slvl 1 to 50 matches 1.13c formula exactly with 0 deviations', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const skillRec = registry.getSkillById(59)!
|
|
expect(skillRec).toBeDefined()
|
|
|
|
// Test every level from 1 to 50
|
|
for (let slvl = 1; slvl <= 50; slvl++) {
|
|
const expected = oracle113cBlizzardDamage(slvl)
|
|
const actual = calculateBlizzardDamage(slvl)
|
|
|
|
expect(actual.min).toBe(expected.min)
|
|
expect(actual.max).toBe(expected.max)
|
|
|
|
// Also compare against D2Common UnitStatList AST evaluation
|
|
const stats = new UnitStatList()
|
|
stats.setBaseSkillLevel(59, slvl)
|
|
const evalRes = skillModule.evaluate!({
|
|
registry,
|
|
skill: skillRec,
|
|
slvl,
|
|
blvl: slvl,
|
|
statList: stats,
|
|
})
|
|
|
|
// HitShift = 8 -> 256 fixed point
|
|
expect(evalRes.minElemDmg256).toBe(expected.min * 256)
|
|
expect(evalRes.maxElemDmg256).toBe(expected.max * 256)
|
|
}
|
|
|
|
// Explicit checkpoint assertions for all 5 band transition points
|
|
// Band 1: slvl 1 (base 45-75), slvl 8 (150-187)
|
|
expect(calculateBlizzardDamage(1)).toEqual({ min: 45, max: 75 })
|
|
expect(calculateBlizzardDamage(8)).toEqual({ min: 150, max: 187 })
|
|
|
|
// Band 2: slvl 9 (180-218), slvl 10 (210-249), slvl 16 (390-435)
|
|
expect(calculateBlizzardDamage(9)).toEqual({ min: 180, max: 218 })
|
|
expect(calculateBlizzardDamage(10)).toEqual({ min: 210, max: 249 })
|
|
expect(calculateBlizzardDamage(16)).toEqual({ min: 390, max: 435 })
|
|
|
|
// Band 3: slvl 17 (435-481), slvl 20 (570-619), slvl 22 (660-711)
|
|
expect(calculateBlizzardDamage(17)).toEqual({ min: 435, max: 481 })
|
|
expect(calculateBlizzardDamage(20)).toEqual({ min: 570, max: 619 })
|
|
expect(calculateBlizzardDamage(22)).toEqual({ min: 660, max: 711 })
|
|
|
|
// Band 4: slvl 23 (715-767), slvl 28 (990-1047)
|
|
expect(calculateBlizzardDamage(23)).toEqual({ min: 715, max: 767 })
|
|
expect(calculateBlizzardDamage(28)).toEqual({ min: 990, max: 1047 })
|
|
|
|
// Band 5: slvl 29 (1055-1113), slvl 30 (1120-1179), slvl 50 (2420-2499)
|
|
expect(calculateBlizzardDamage(29)).toEqual({ min: 1055, max: 1113 })
|
|
expect(calculateBlizzardDamage(30)).toEqual({ min: 1120, max: 1179 })
|
|
expect(calculateBlizzardDamage(50)).toEqual({ min: 2420, max: 2499 })
|
|
})
|
|
|
|
it('2. Synergy multiplier matches +5% per hard point with 1,000 randomized combination fuzzing', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const skillRec = registry.getSkillById(59)!
|
|
|
|
// Test zero synergies
|
|
expect(calculateBlizzardSynergyMultiplier({})).toBe(1.0)
|
|
expect(calculateBlizzardDamage(20, {})).toEqual({ min: 570, max: 619 })
|
|
|
|
// Test individual synergies
|
|
expect(calculateBlizzardSynergyMultiplier({ iceBolt: 1 })).toBeCloseTo(1.05, 5)
|
|
expect(calculateBlizzardSynergyMultiplier({ iceBlast: 1 })).toBeCloseTo(1.05, 5)
|
|
expect(calculateBlizzardSynergyMultiplier({ glacialSpike: 1 })).toBeCloseTo(1.05, 5)
|
|
|
|
// Test 20 in single skill: +100% (2.0x)
|
|
expect(calculateBlizzardSynergyMultiplier({ iceBolt: 20 })).toBe(2.0)
|
|
expect(calculateBlizzardDamage(20, { iceBolt: 20 })).toEqual({ min: 1140, max: 1238 })
|
|
|
|
// Test 20 in two skills: +200% (3.0x)
|
|
expect(calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20 })).toBe(3.0)
|
|
expect(calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20 })).toEqual({ min: 1710, max: 1857 })
|
|
|
|
// Test max legal synergies (60 points total): +300% (4.0x)
|
|
expect(
|
|
calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20, glacialSpike: 20 }),
|
|
).toBe(4.0)
|
|
expect(
|
|
calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20, glacialSpike: 20 }),
|
|
).toEqual({ min: 2280, max: 2476 })
|
|
|
|
// Fuzz test 1,000 random synergy triples (p1, p2, p3) in range [0..20]
|
|
for (let i = 0; i < 1000; i++) {
|
|
const p1 = Math.floor(Math.random() * 21)
|
|
const p2 = Math.floor(Math.random() * 21)
|
|
const p3 = Math.floor(Math.random() * 21)
|
|
const synObj = { iceBolt: p1, iceBlast: p2, glacialSpike: p3 }
|
|
|
|
const expectedSyn = 1.0 + (p1 + p2 + p3) * 0.05
|
|
const actualSyn = calculateBlizzardSynergyMultiplier(synObj)
|
|
expect(actualSyn).toBeCloseTo(expectedSyn, 5)
|
|
|
|
const slvl = 1 + Math.floor(Math.random() * 40)
|
|
const expectedDmg = oracle113cBlizzardDamage(slvl, synObj)
|
|
const actualDmg = calculateBlizzardDamage(slvl, synObj)
|
|
expect(actualDmg.min).toBe(expectedDmg.min)
|
|
expect(actualDmg.max).toBe(expectedDmg.max)
|
|
}
|
|
|
|
// Verify 1.13c hard point invariant: soft skills (+skills, charged skills) produce ZERO synergy
|
|
const statsSoft = new UnitStatList()
|
|
statsSoft.setBaseSkillLevel(59, 20)
|
|
statsSoft.addStat('item_allskills', 20) // +20 all skills
|
|
statsSoft.setChargedSkillLevel(39, 33) // 33 charges of Ice Bolt (Marrowwalk bug test)
|
|
statsSoft.setChargedSkillLevel(45, 33) // 33 charges of Ice Blast
|
|
statsSoft.setChargedSkillLevel(55, 33) // 33 charges of Glacial Spike
|
|
|
|
const evalSoft = skillModule.evaluate!({
|
|
registry,
|
|
skill: skillRec,
|
|
slvl: 40,
|
|
blvl: 20,
|
|
statList: statsSoft,
|
|
})
|
|
expect(evalSoft.synergyBonusPct).toBe(0)
|
|
})
|
|
|
|
it('3. Authoritative 64-entry circle table values match D2Game.dll 0x6FD1C960 & 0x6FD1CA60 with 0 errors', () => {
|
|
// Read D2Game.dll directly from 1.13c installation on disk
|
|
const dllPath = '/usr/local/google/home/taodao/d2-data/D2Game.dll'
|
|
expect(fs.existsSync(dllPath), `Missing D2Game.dll at ${dllPath}`).toBe(true)
|
|
|
|
const dllBuffer = fs.readFileSync(dllPath)
|
|
|
|
// Offsets in D2Game.dll file:
|
|
// ImageBase = 0x6FC20000, .rdata file offset = 0xF8000, VA = 0x6FD18000
|
|
// VA 0x6FD1C960 -> file offset 0xFC960
|
|
// VA 0x6FD1CA60 -> file offset 0xFCA60
|
|
const fileOffsetTableX = 0xFC960
|
|
const fileOffsetTableY = 0xFCA60
|
|
|
|
expect(BLIZZARD_CIRCLE_X.length).toBe(64)
|
|
expect(BLIZZARD_CIRCLE_Y.length).toBe(64)
|
|
expect(Object.isFrozen(BLIZZARD_CIRCLE_X)).toBe(true)
|
|
expect(Object.isFrozen(BLIZZARD_CIRCLE_Y)).toBe(true)
|
|
|
|
const dllCircleX: number[] = []
|
|
const dllCircleY: number[] = []
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
dllCircleX.push(dllBuffer.readInt32LE(fileOffsetTableX + i * 4))
|
|
dllCircleY.push(dllBuffer.readInt32LE(fileOffsetTableY + i * 4))
|
|
}
|
|
|
|
// Compare all 64 coordinates byte-for-byte against D2Game.dll
|
|
let mismatchCount = 0
|
|
const mismatches: string[] = []
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
const codeX = BLIZZARD_CIRCLE_X[i]
|
|
const codeY = BLIZZARD_CIRCLE_Y[i]
|
|
const binaryX = dllCircleX[i]
|
|
const binaryY = dllCircleY[i]
|
|
|
|
if (codeX !== binaryX || codeY !== binaryY) {
|
|
mismatchCount++
|
|
mismatches.push(
|
|
`Index ${i}: code=(${codeX}, ${codeY}) vs dll=(${binaryX}, ${binaryY})`,
|
|
)
|
|
}
|
|
}
|
|
|
|
expect(mismatchCount).toBe(0)
|
|
expect(mismatches).toEqual([])
|
|
})
|
|
|
|
it('4. Exactly 25 shards dropped over 100 ticks (cadence 4, step 13 mod 64) with zero clustering', () => {
|
|
let center: Projectile = {
|
|
skillId: '59',
|
|
x: 500,
|
|
y: 500,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 250,
|
|
ttl: 100, // 100 ticks = 4.0s
|
|
fromPlayer: true,
|
|
missileType: 'blizzardcenter',
|
|
subMissileIdx: 0,
|
|
}
|
|
|
|
const emittedShards: Projectile[] = []
|
|
const angleIndicesUsed: number[] = []
|
|
const dummyTerrain = { overlap: () => 0 }
|
|
|
|
for (let tick = 1; tick <= 100; tick++) {
|
|
const outcome = tickProjectiles([center], [], dummyTerrain)
|
|
|
|
if (tick % 4 === 0) {
|
|
expect(outcome.spawnedProjectiles).toBeDefined()
|
|
expect(outcome.spawnedProjectiles!.length).toBe(1)
|
|
|
|
const shard = outcome.spawnedProjectiles![0]!
|
|
expect(['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4']).toContain(shard.missileType)
|
|
expect(shard.ttl).toBe(9)
|
|
expect(shard.altitude).toBe(120)
|
|
expect(shard.damage).toBe(250)
|
|
expect(shard.statusEffect).toBe('chill')
|
|
expect(shard.statusDuration).toBe(100)
|
|
|
|
emittedShards.push(shard)
|
|
} else {
|
|
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
|
|
}
|
|
|
|
center = outcome.alive.find(p => p.missileType === 'blizzardcenter')!
|
|
if (tick < 100) {
|
|
expect(center).toBeDefined()
|
|
expect(center.ttl).toBe(100 - tick)
|
|
}
|
|
}
|
|
|
|
// 1. Total count is strictly 25 shards
|
|
expect(emittedShards.length).toBe(25)
|
|
|
|
// 2. Center expires at tick 101
|
|
const finalOutcome = tickProjectiles([center], [], dummyTerrain)
|
|
expect(finalOutcome.expired).toBe(1)
|
|
expect(finalOutcome.alive.length).toBe(0)
|
|
|
|
// 3. Step 13 mod 64 sequence: gcd(13, 64) = 1, ensuring zero duplicate indices in 25 drops
|
|
for (let k = 0; k < 25; k++) {
|
|
const expectedIndex = (k * 13) % 64
|
|
angleIndicesUsed.push(expectedIndex)
|
|
}
|
|
const uniqueIndices = new Set(angleIndicesUsed)
|
|
expect(uniqueIndices.size).toBe(25) // All 25 drops use distinct angle indices!
|
|
})
|
|
|
|
it('5. 2:1 isometric aspect ratio scaling on ground plane (dy = tblY * scale * 0.5)', () => {
|
|
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
|
|
|
|
const radiusSubtiles = 7
|
|
const subtileToPx = SUBTILE_TO_PX // 16
|
|
const scale = (radiusSubtiles * subtileToPx) / 30 // 112 / 30 = 3.7333333333333334
|
|
|
|
// Test all 64 drop offsets
|
|
for (let i = 0; i < 64; i++) {
|
|
const offset = getBlizzardDropOffset(i, radiusSubtiles, subtileToPx)
|
|
const rawX = BLIZZARD_CIRCLE_X[i]!
|
|
const rawY = BLIZZARD_CIRCLE_Y[i]!
|
|
|
|
const expectedDx = rawX * scale
|
|
const expectedDy = rawY * scale * 0.5
|
|
|
|
expect(offset.dx).toBeCloseTo(expectedDx, 10)
|
|
expect(offset.dy).toBeCloseTo(expectedDy, 10)
|
|
|
|
// When rawY is non-zero, aspect ratio dy / (rawY * scale) must be strictly 0.5
|
|
if (rawY !== 0) {
|
|
const uncompressedY = rawY * scale
|
|
expect(offset.dy / uncompressedY).toBeCloseTo(0.5, 10)
|
|
}
|
|
}
|
|
|
|
// Cardinal coordinate checks:
|
|
// East: index 16 (X=30, Y=0) -> dx = 112, dy = 0
|
|
const east = getBlizzardDropOffset(16, 7, 16)
|
|
expect(east.dx).toBe(112)
|
|
expect(east.dy).toBe(0)
|
|
|
|
// South: index 0 (X=0, Y=30) -> dx = 0, dy = 56
|
|
const south = getBlizzardDropOffset(0, 7, 16)
|
|
expect(south.dx).toBe(0)
|
|
expect(south.dy).toBe(56)
|
|
|
|
// West: index 48 (X=-30, Y=0) -> dx = -112, dy = 0
|
|
const west = getBlizzardDropOffset(48, 7, 16)
|
|
expect(west.dx).toBe(-112)
|
|
expect(west.dy).toBe(0)
|
|
|
|
// North: index 32 (X=0, Y=-30) -> dx = 0, dy = -56
|
|
const north = getBlizzardDropOffset(32, 7, 16)
|
|
expect(north.dx).toBe(0)
|
|
expect(north.dy).toBe(-56)
|
|
|
|
// Bounding ellipse width and height:
|
|
// Full width = 2 * 112 = 224 px (14 subtiles = 7 subtiles radius)
|
|
// Full height = 2 * 56 = 112 px (7 subtiles = 3.5 subtiles vertical radius)
|
|
// Ratio height / width = 112 / 224 = 0.5 (2:1 isometric ellipse)
|
|
expect(56 / 112).toBe(0.5)
|
|
})
|
|
|
|
it('6. Falling shard altitude descends vertically from 120 px to 0 over 9 ticks and detonates on impact', () => {
|
|
let shard: Projectile = {
|
|
skillId: '59',
|
|
x: 200,
|
|
y: 200,
|
|
vx: 0,
|
|
vy: 0,
|
|
altitude: 120,
|
|
damage: 180,
|
|
ttl: 9,
|
|
fromPlayer: true,
|
|
missileType: 'blizzard1',
|
|
}
|
|
|
|
const dummyTerrain = { overlap: () => 0 }
|
|
|
|
for (let t = 1; t <= 9; t++) {
|
|
const outcome = tickProjectiles([shard], [], dummyTerrain)
|
|
shard = outcome.alive.find(p => p.missileType === 'blizzard1')!
|
|
|
|
if (t < 9) {
|
|
expect(shard).toBeDefined()
|
|
expect(shard.ttl).toBe(9 - t)
|
|
const expectedAlt = 120 - t * (120 / 9)
|
|
expect(shard.altitude).toBeCloseTo(expectedAlt, 0.1)
|
|
} else {
|
|
expect(shard.ttl).toBe(0)
|
|
}
|
|
}
|
|
|
|
// Ttl = 0 expires and detonates ground explosion
|
|
const detonation = tickProjectiles([shard], [], dummyTerrain)
|
|
expect(detonation.expired).toBe(1)
|
|
expect(detonation.spawnedProjectiles!.length).toBe(1)
|
|
const exp = detonation.spawnedProjectiles![0]!
|
|
expect(exp.missileType).toBe('blizzardexplode1')
|
|
expect(exp.damage).toBe(180)
|
|
expect(exp.ttl).toBe(6)
|
|
expect(exp.statusEffect).toBe('chill')
|
|
expect(exp.statusDuration).toBe(100)
|
|
})
|
|
|
|
it('7. NextDelay = 0 sweet-spot burst collision: overlapping shards deal full stacked damage', () => {
|
|
const shardCount = 5
|
|
const shards: Projectile[] = []
|
|
|
|
for (let i = 0; i < shardCount; i++) {
|
|
shards.push({
|
|
skillId: '59',
|
|
x: 300 + (i % 2),
|
|
y: 300,
|
|
vx: 0,
|
|
vy: 0,
|
|
altitude: 10,
|
|
damage: 150,
|
|
ttl: 2,
|
|
fromPlayer: true,
|
|
missileType: `blizzard${(i % 4) + 1}`,
|
|
})
|
|
}
|
|
|
|
const bossMonster = [{ index: 42, x: 300, y: 300, radius: 25, alive: true }]
|
|
const outcome = tickProjectiles(shards, bossMonster, { overlap: () => 0 })
|
|
|
|
// All 5 shards must hit the target without being blocked by NextDelay immunity frames
|
|
expect(outcome.hits.length).toBe(shardCount)
|
|
for (const hit of outcome.hits) {
|
|
expect(hit.targetIndex).toBe(42)
|
|
expect(hit.damage).toBe(150)
|
|
expect(hit.statusEffect).toBe('chill')
|
|
expect(hit.statusDuration).toBe(100)
|
|
}
|
|
|
|
// Total damage dealt across the single frame is exactly 5 * 150 = 750
|
|
const totalDmg = outcome.hits.reduce((sum, h) => sum + h.damage, 0)
|
|
expect(totalDmg).toBe(750)
|
|
|
|
// All 5 shards must spawn explosion missiles
|
|
expect(outcome.spawnedProjectiles!.length).toBe(shardCount)
|
|
})
|
|
|
|
it('8. High-density performance stress test: 100 concurrent Blizzard centers execute well under 50% budget', () => {
|
|
// 100 concurrent Blizzard centers active simultaneously
|
|
const centers: Projectile[] = []
|
|
for (let c = 0; c < 100; c++) {
|
|
centers.push({
|
|
skillId: '59',
|
|
x: 100 + c * 5,
|
|
y: 100 + c * 5,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 300,
|
|
ttl: 100,
|
|
fromPlayer: true,
|
|
missileType: 'blizzardcenter',
|
|
subMissileIdx: c % 64,
|
|
})
|
|
}
|
|
|
|
let activeProjectiles: Projectile[] = [...centers]
|
|
let totalShardsSpawned = 0
|
|
let totalExplosionsSpawned = 0
|
|
const dummyTerrain = { overlap: () => 0 }
|
|
|
|
const startTime = performance.now()
|
|
|
|
// Simulate 100 ticks (2,500 falling shards + 4,400 ground explosions spawned + simulated)
|
|
for (let tick = 1; tick <= 100; tick++) {
|
|
const outcome = tickProjectiles(activeProjectiles, [], dummyTerrain)
|
|
activeProjectiles = [...outcome.alive]
|
|
if (outcome.spawnedProjectiles) {
|
|
for (const p of outcome.spawnedProjectiles) {
|
|
if (['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'].includes(p.missileType ?? '')) {
|
|
totalShardsSpawned++
|
|
} else if (p.missileType?.startsWith('blizzardexplode')) {
|
|
totalExplosionsSpawned++
|
|
}
|
|
}
|
|
activeProjectiles.push(...outcome.spawnedProjectiles)
|
|
}
|
|
}
|
|
|
|
const elapsedMs = performance.now() - startTime
|
|
|
|
// 100 centers * 25 shards = 2,500 shards spawned
|
|
expect(totalShardsSpawned).toBe(2500)
|
|
expect(totalExplosionsSpawned).toBe(4400)
|
|
|
|
// 100 frames simulation of 100 centers + 2,500 shards + 4,400 explosions should execute in < 500ms
|
|
expect(elapsedMs).toBeLessThan(500)
|
|
})
|
|
})
|