Compare commits
No commits in common. "39de19364f59ef158b8e0fc857e65cd92866deff" and "6f9b2e7fb12341dd62054ac924e2e8bb2114fb87" have entirely different histories.
39de19364f
...
6f9b2e7fb1
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"name": "blizzard1",
|
||||
"celFile": "icestormfallvar01",
|
||||
"width": 512,
|
||||
"height": 198,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 6,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
56,
|
||||
198,
|
||||
-50,
|
||||
-208
|
||||
],
|
||||
[
|
||||
57,
|
||||
0,
|
||||
56,
|
||||
198,
|
||||
-50,
|
||||
-208
|
||||
],
|
||||
[
|
||||
114,
|
||||
0,
|
||||
56,
|
||||
198,
|
||||
-50,
|
||||
-208
|
||||
],
|
||||
[
|
||||
171,
|
||||
0,
|
||||
56,
|
||||
198,
|
||||
-50,
|
||||
-208
|
||||
],
|
||||
[
|
||||
228,
|
||||
0,
|
||||
56,
|
||||
198,
|
||||
-50,
|
||||
-208
|
||||
],
|
||||
[
|
||||
285,
|
||||
0,
|
||||
56,
|
||||
198,
|
||||
-50,
|
||||
-208
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"name": "blizzard2",
|
||||
"celFile": "icestormfallvar02",
|
||||
"width": 512,
|
||||
"height": 195,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 6,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
48,
|
||||
195,
|
||||
-46,
|
||||
-201
|
||||
],
|
||||
[
|
||||
49,
|
||||
0,
|
||||
48,
|
||||
195,
|
||||
-46,
|
||||
-201
|
||||
],
|
||||
[
|
||||
98,
|
||||
0,
|
||||
48,
|
||||
195,
|
||||
-46,
|
||||
-201
|
||||
],
|
||||
[
|
||||
147,
|
||||
0,
|
||||
48,
|
||||
195,
|
||||
-46,
|
||||
-201
|
||||
],
|
||||
[
|
||||
196,
|
||||
0,
|
||||
48,
|
||||
195,
|
||||
-46,
|
||||
-201
|
||||
],
|
||||
[
|
||||
245,
|
||||
0,
|
||||
48,
|
||||
195,
|
||||
-46,
|
||||
-201
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
|
@ -1,77 +0,0 @@
|
|||
{
|
||||
"name": "blizzard3",
|
||||
"celFile": "Blizzard",
|
||||
"width": 512,
|
||||
"height": 307,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 8,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
98,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
196,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
294,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
392,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
0,
|
||||
154,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
98,
|
||||
154,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
196,
|
||||
154,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
|
@ -1,77 +0,0 @@
|
|||
{
|
||||
"name": "blizzard4",
|
||||
"celFile": "Blizzard",
|
||||
"width": 512,
|
||||
"height": 307,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 8,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
98,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
196,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
294,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
392,
|
||||
0,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
0,
|
||||
154,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
98,
|
||||
154,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
],
|
||||
[
|
||||
196,
|
||||
154,
|
||||
97,
|
||||
153,
|
||||
-71,
|
||||
-153
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"name": "blizzardexplode1",
|
||||
"celFile": "icestormimpactvar01",
|
||||
"width": 512,
|
||||
"height": 145,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 6,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
113,
|
||||
72,
|
||||
-54,
|
||||
-31
|
||||
],
|
||||
[
|
||||
114,
|
||||
0,
|
||||
113,
|
||||
72,
|
||||
-54,
|
||||
-31
|
||||
],
|
||||
[
|
||||
228,
|
||||
0,
|
||||
113,
|
||||
72,
|
||||
-54,
|
||||
-31
|
||||
],
|
||||
[
|
||||
342,
|
||||
0,
|
||||
113,
|
||||
72,
|
||||
-54,
|
||||
-31
|
||||
],
|
||||
[
|
||||
0,
|
||||
73,
|
||||
113,
|
||||
72,
|
||||
-54,
|
||||
-31
|
||||
],
|
||||
[
|
||||
114,
|
||||
73,
|
||||
113,
|
||||
72,
|
||||
-54,
|
||||
-31
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"name": "blizzardexplode2",
|
||||
"celFile": "icestormimpactvar02",
|
||||
"width": 512,
|
||||
"height": 141,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 6,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
118,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
236,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
354,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
0,
|
||||
71,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
118,
|
||||
71,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"name": "blizzardexplode3",
|
||||
"celFile": "icestormimpactvar02",
|
||||
"width": 512,
|
||||
"height": 141,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 6,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
118,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
236,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
354,
|
||||
0,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
0,
|
||||
71,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
],
|
||||
[
|
||||
118,
|
||||
71,
|
||||
117,
|
||||
70,
|
||||
-55,
|
||||
-30
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -17,7 +17,7 @@
|
|||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
|
|
@ -69,11 +69,6 @@ const MISSILE_TARGETS = [
|
|||
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
|
||||
{ name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 },
|
||||
{ name: 'frozenorbexplode', celFile: 'IceOrbExplode', animSpeed: 16 },
|
||||
{ name: 'blizzard1', celFile: 'icestormfallvar01', animSpeed: 16 },
|
||||
{ name: 'blizzard2', celFile: 'icestormfallvar02', animSpeed: 16 },
|
||||
{ name: 'blizzard3', celFile: 'Blizzard', animSpeed: 16 },
|
||||
{ name: 'blizzardexplode1', celFile: 'icestormimpactvar01', animSpeed: 16 },
|
||||
{ name: 'blizzardexplode2', celFile: 'icestormimpactvar02', animSpeed: 16 },
|
||||
|
||||
// Lightning
|
||||
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },
|
||||
|
|
@ -232,9 +227,6 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
|
|||
join(projectRoot, 'samples', 'd2', 'Patch_D2.mpq'),
|
||||
join(projectRoot, 'samples', 'd2', 'd2exp.mpq'),
|
||||
join(projectRoot, 'samples', 'd2', 'd2data.mpq'),
|
||||
'/usr/local/google/home/taodao/d2-data/Patch_D2.mpq',
|
||||
'/usr/local/google/home/taodao/d2-data/d2exp.mpq',
|
||||
'/usr/local/google/home/taodao/d2-data/d2data.mpq',
|
||||
]
|
||||
const archives: MpqArchive[] = []
|
||||
for (const p of archivePaths) {
|
||||
|
|
@ -309,22 +301,6 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
|
|||
console.log(`[Missile Packer] Baked ${target.name} (${target.celFile}.dcc): ${packed.width}x${packed.height}, ${packed.png.byteLength} bytes PNG`)
|
||||
}
|
||||
|
||||
// Aliases for blizzard4 (from blizzard3) and blizzardexplode3 (from blizzardexplode2)
|
||||
const copyAlias = (aliasName: string, sourceName: string) => {
|
||||
const srcMeta = metaRecord[sourceName]
|
||||
if (!srcMeta) return
|
||||
const aliasMeta: MissileMeta = { ...srcMeta, name: aliasName }
|
||||
const jsonStr = JSON.stringify(aliasMeta, null, 2)
|
||||
writeFileSync(join(publicOutDir, `${aliasName}.json`), jsonStr, 'utf-8')
|
||||
writeFileSync(join(packOutDir, `${aliasName}.json`), jsonStr, 'utf-8')
|
||||
const srcPngBytes = readFileSync(join(publicOutDir, `${sourceName}.png`))
|
||||
writeFileSync(join(publicOutDir, `${aliasName}.png`), srcPngBytes)
|
||||
writeFileSync(join(packOutDir, `${aliasName}.png`), srcPngBytes)
|
||||
console.log(`[Missile Packer] Aliased ${aliasName} -> ${sourceName}`)
|
||||
}
|
||||
copyAlias('blizzard4', 'blizzard3')
|
||||
copyAlias('blizzardexplode3', 'blizzardexplode2')
|
||||
|
||||
// Generate src/render/missiles-meta.ts
|
||||
const metaExports = MISSILE_TARGETS.map(t => {
|
||||
const upper = t.name.toUpperCase()
|
||||
|
|
@ -425,32 +401,8 @@ export function velocityToDccDirection(vx: number, vy: number, directions = 16):
|
|||
|
||||
${metaExports}
|
||||
|
||||
export const BLIZZARD4_META: MissileMeta = {
|
||||
...BLIZZARD3_META,
|
||||
name: 'blizzard4',
|
||||
}
|
||||
|
||||
export const BLIZZARDEXPLODE3_META: MissileMeta = {
|
||||
...BLIZZARDEXPLODE2_META,
|
||||
name: 'blizzardexplode3',
|
||||
}
|
||||
|
||||
export const HYDRA_META: MissileMeta = {
|
||||
...FIREBOLT_META,
|
||||
name: 'hydra',
|
||||
}
|
||||
|
||||
export const HYDRAFIRE_META: MissileMeta = {
|
||||
...FIREBOLT_META,
|
||||
name: 'hydrafire',
|
||||
}
|
||||
|
||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||
${mapEntries}
|
||||
'blizzard4': BLIZZARD4_META,
|
||||
'blizzardexplode3': BLIZZARDEXPLODE3_META,
|
||||
'hydra': HYDRA_META,
|
||||
'hydrafire': HYDRAFIRE_META,
|
||||
})
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Blizzard (Skill 59) Circle Table.
|
||||
*
|
||||
* Authoritative 64-entry circle lookup table from D2Game.dll (VA 0x6FD1C960, 0x6FD1CA60).
|
||||
* Table radius = 30 subtiles.
|
||||
*
|
||||
* Used by MISSMODE_SrvDo10_blizzardcenter (0x6FC60CA0) to disperse falling shards
|
||||
* across a radius of 7 subtiles with 2:1 isometric ground projection (Y * 0.5).
|
||||
*/
|
||||
|
||||
export const BLIZZARD_CIRCLE_X: readonly number[] = Object.freeze([
|
||||
0, 2, 5, 8, 11, 14, 16, 19,
|
||||
21, 23, 24, 26, 27, 28, 29, 29,
|
||||
30, 29, 29, 28, 27, 26, 24, 23,
|
||||
21, 19, 16, 14, 11, 8, 5, 2,
|
||||
0, -2, -5, -8, -11, -14, -16, -19,
|
||||
-21, -23, -24, -26, -27, -28, -29, -29,
|
||||
-30, -29, -29, -28, -27, -26, -24, -23,
|
||||
-21, -19, -16, -14, -11, -8, -5, -2,
|
||||
])
|
||||
|
||||
export const BLIZZARD_CIRCLE_Y: readonly number[] = Object.freeze([
|
||||
30, 29, 29, 28, 27, 26, 24, 23,
|
||||
21, 19, 16, 14, 11, 8, 5, 2,
|
||||
0, -2, -5, -8, -11, -14, -16, -19,
|
||||
-21, -23, -24, -26, -27, -28, -29, -29,
|
||||
-30, -29, -29, -28, -27, -26, -24, -23,
|
||||
-21, -19, -16, -14, -11, -8, -5, -2,
|
||||
0, 2, 5, 8, 11, 14, 16, 19,
|
||||
21, 23, 24, 26, 27, 28, 29, 29,
|
||||
])
|
||||
|
||||
/**
|
||||
* 2:1 Isometric ground projection ratio.
|
||||
*/
|
||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||
|
||||
/**
|
||||
* Default subtile to world pixel conversion ratio.
|
||||
* 1 full tile = 160x80 px = 5x5 subtiles -> 1 subtile = 16 px half-width (32 px full-width).
|
||||
*/
|
||||
export const SUBTILE_TO_PX = 16
|
||||
|
||||
/**
|
||||
* Calculates ground target offset for a Blizzard falling shard using the 64-entry circle table
|
||||
* with 2:1 isometric ground foreshortening (Y * 0.5).
|
||||
*
|
||||
* @param angleIdx - Angle index in the 64-entry table (0..63)
|
||||
* @param radiusSubtiles - Radius in subtiles (default: 7 from Skills.txt Param1)
|
||||
* @param subtileToPx - Subtile to world pixel ratio (default: 16)
|
||||
* @returns { dx, dy } in world pixel coordinates
|
||||
*/
|
||||
export function getBlizzardDropOffset(
|
||||
angleIdx: number,
|
||||
radiusSubtiles = 7,
|
||||
subtileToPx = SUBTILE_TO_PX,
|
||||
): { dx: number; dy: number } {
|
||||
const idx = Math.abs(angleIdx % 64)
|
||||
const tblX = BLIZZARD_CIRCLE_X[idx]!
|
||||
const tblY = BLIZZARD_CIRCLE_Y[idx]!
|
||||
const scale = (radiusSubtiles * subtileToPx) / 30
|
||||
const dx = tblX * scale
|
||||
const dy = tblY * scale * ISO_GROUND_ASPECT_RATIO
|
||||
return { dx, dy }
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
*/
|
||||
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts'
|
||||
import { getBlizzardDropOffset } from './blizzard-table.ts'
|
||||
|
||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||
|
||||
|
|
@ -247,20 +246,17 @@ export class MissileEngine {
|
|||
const dx = params.targetX - params.startX
|
||||
const dy = params.targetY - params.startY
|
||||
const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
||||
const speedPxPerTick = Math.max(8, effectiveRec.vel || 18)
|
||||
const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret')
|
||||
const isStationary =
|
||||
isTurret ||
|
||||
effectiveRec.vel === 0 ||
|
||||
effectiveRec.name.toLowerCase().startsWith('blizzard')
|
||||
const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18)
|
||||
const vx = isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick
|
||||
const vy = isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick
|
||||
const baseRange = effectiveRec.range !== undefined && effectiveRec.range > 0 ? effectiveRec.range : 35
|
||||
const calculatedRange = baseRange + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0)
|
||||
const vx = isTurret ? 0 : Math.cos(angleRad) * speedPxPerTick
|
||||
const vy = isTurret ? 0 : Math.sin(angleRad) * speedPxPerTick
|
||||
const maxRangeTicks = params.maxRangeTicks ?? (
|
||||
isTurret
|
||||
? 250 // 1.13c Hydra duration: 250 frames (10 seconds)
|
||||
: calculatedRange
|
||||
: Math.max(
|
||||
10,
|
||||
(effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0),
|
||||
)
|
||||
)
|
||||
|
||||
const missile: ActiveMissile = {
|
||||
|
|
@ -370,35 +366,6 @@ export class MissileEngine {
|
|||
}
|
||||
}
|
||||
|
||||
// `pSrvDoFunc = 10` (`blizzardcenter`): emits falling shards across 64-entry circle table
|
||||
// Authentic D2Game.0x6FC60CA0 (MISSMODE_SrvDo10_blizzardcenter):
|
||||
// - Trigger frequency: dwParam[2] (4 frames, Param3 in Skills.txt)
|
||||
// - Angular step: 13 (coprime with 64)
|
||||
// - Radius: 7 subtiles
|
||||
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
|
||||
if (m.record.pSrvDoFunc === 10 || m.name === 'blizzardcenter') {
|
||||
const cadence = Math.max(1, m.record.param3 || 4)
|
||||
if (m.ageTicks % cadence === 0) {
|
||||
const idx = Math.abs((m.subMissileIdx ?? 0) % 64)
|
||||
const offset = getBlizzardDropOffset(idx, 7)
|
||||
const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const
|
||||
const shardName = shardVariants[Math.floor((m.ageTicks / cadence) - 1) % shardVariants.length]!
|
||||
this.spawnMissile({
|
||||
missileNameOrId: shardName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x + offset.dx,
|
||||
startY: m.y + offset.dy,
|
||||
targetX: m.x + offset.dx,
|
||||
targetY: m.y + offset.dy,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(shardName)
|
||||
m.subMissileIdx = (idx + 13) % 64
|
||||
}
|
||||
}
|
||||
|
||||
// Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target
|
||||
const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret')
|
||||
if (isTurretEmitter) {
|
||||
|
|
@ -467,14 +434,9 @@ export class MissileEngine {
|
|||
continue
|
||||
}
|
||||
|
||||
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), and Hydra turret emitters
|
||||
// do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
|
||||
if (
|
||||
m.record.pSrvDoFunc === 15 ||
|
||||
m.record.pSrvDoFunc === 10 ||
|
||||
m.name === 'blizzardcenter' ||
|
||||
isTurretEmitter
|
||||
) {
|
||||
// Main Frozen Orb (pSrvDoFunc === 15) and Hydra turret emitters do not deal direct contact damage;
|
||||
// all damage is delivered through emitted child bolts.
|
||||
if (m.record.pSrvDoFunc === 15 || isTurretEmitter) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -589,25 +551,6 @@ export class MissileEngine {
|
|||
if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||
m.pierceCount += 1
|
||||
} else if (m.record.collideKill || !m.canPierce) {
|
||||
if (
|
||||
m.name.startsWith('blizzard') &&
|
||||
!m.name.includes('explode') &&
|
||||
m.name !== 'blizzardcenter'
|
||||
) {
|
||||
const expName = m.record.explosionMissile || 'blizzardexplode1'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
}
|
||||
m.expired = true
|
||||
break
|
||||
}
|
||||
|
|
@ -615,29 +558,6 @@ export class MissileEngine {
|
|||
}
|
||||
|
||||
if (m.ageTicks >= m.maxRangeTicks) {
|
||||
// Blizzard falling shard ground impact explosion (Missiles 159..162)
|
||||
if (
|
||||
m.name.startsWith('blizzard') &&
|
||||
!m.name.includes('explode') &&
|
||||
m.name !== 'blizzardcenter' &&
|
||||
(!m.currentHitFrame || m.currentHitFrame === 0)
|
||||
) {
|
||||
m.currentHitFrame = 1
|
||||
const expName = m.record.explosionMissile || 'blizzardexplode1'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
}
|
||||
|
||||
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
|
||||
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
|
||||
// - Triggers only on first frame of hit (currentHitFrame === 0)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,6 @@ import type { Rng } from './rng.ts'
|
|||
import type { CharacterClassCode } from './classes.ts'
|
||||
import type { CombatPlayer } from './combat.ts'
|
||||
import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts'
|
||||
import {
|
||||
BLIZZARD_CIRCLE_X,
|
||||
BLIZZARD_CIRCLE_Y,
|
||||
getBlizzardDropOffset,
|
||||
} from './engine/blizzard-table.ts'
|
||||
export { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, getBlizzardDropOffset } from './engine/blizzard-table.ts'
|
||||
|
||||
/** One castable skill, read from a table row. */
|
||||
export interface SkillDef {
|
||||
|
|
@ -121,8 +115,6 @@ export interface Projectile {
|
|||
readonly spawnMissileType?: string | undefined
|
||||
/** Number of elapsed ticks this projectile has lived. */
|
||||
readonly ageTicks?: number | undefined
|
||||
/** Altitude above ground in pixels for falling missiles (e.g. Blizzard shards). */
|
||||
readonly altitude?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -467,166 +459,6 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardcenter: {
|
||||
name: 'blizzardcenter',
|
||||
id: 158,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 100,
|
||||
levRange: 0,
|
||||
celFile: '',
|
||||
animLen: 2,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 0,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard1: {
|
||||
name: 'blizzard1',
|
||||
id: 159,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 9,
|
||||
levRange: 0,
|
||||
celFile: 'icestormfallvar01',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: 'blizzardexplode1',
|
||||
light: 4,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard2: {
|
||||
name: 'blizzard2',
|
||||
id: 160,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 9,
|
||||
levRange: 0,
|
||||
celFile: 'icestormfallvar02',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: 'blizzardexplode2',
|
||||
light: 4,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard3: {
|
||||
name: 'blizzard3',
|
||||
id: 161,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 9,
|
||||
levRange: 0,
|
||||
celFile: 'Blizzard',
|
||||
animLen: 8,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: 'blizzardexplode3',
|
||||
light: 4,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard4: {
|
||||
name: 'blizzard4',
|
||||
id: 162,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 9,
|
||||
levRange: 0,
|
||||
celFile: 'Blizzard',
|
||||
animLen: 8,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: 'blizzardexplode1',
|
||||
light: 4,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardexplode1: {
|
||||
name: 'blizzardexplode1',
|
||||
id: 163,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 6,
|
||||
levRange: 0,
|
||||
celFile: 'icestormimpactvar01',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 6,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardexplode2: {
|
||||
name: 'blizzardexplode2',
|
||||
id: 164,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 6,
|
||||
levRange: 0,
|
||||
celFile: 'icestormimpactvar02',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 6,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardexplode3: {
|
||||
name: 'blizzardexplode3',
|
||||
id: 165,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 6,
|
||||
levRange: 0,
|
||||
celFile: 'icestormimpactvar02',
|
||||
animLen: 6,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 6,
|
||||
red: 81,
|
||||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
hydra: {
|
||||
name: 'hydra',
|
||||
id: 247,
|
||||
|
|
@ -3425,30 +3257,6 @@ export interface ProjectileOutcome {
|
|||
readonly spawnedProjectiles?: readonly Projectile[] | undefined
|
||||
}
|
||||
|
||||
function getBlizzardExplosionMissile(missileType: string): string {
|
||||
switch (missileType) {
|
||||
case 'blizzard1':
|
||||
return 'blizzardexplode1'
|
||||
case 'blizzard2':
|
||||
return 'blizzardexplode2'
|
||||
case 'blizzard3':
|
||||
return 'blizzardexplode3'
|
||||
case 'blizzard4':
|
||||
return 'blizzardexplode1'
|
||||
default:
|
||||
return 'blizzardexplode1'
|
||||
}
|
||||
}
|
||||
|
||||
function isBlizzardShard(missileType?: string): boolean {
|
||||
return (
|
||||
missileType === 'blizzard1' ||
|
||||
missileType === 'blizzard2' ||
|
||||
missileType === 'blizzard3' ||
|
||||
missileType === 'blizzard4'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance every projectile one tick.
|
||||
*
|
||||
|
|
@ -3525,23 +3333,6 @@ export function tickProjectiles(
|
|||
missileType: 'frozenorb',
|
||||
aoeRadius: 24,
|
||||
})
|
||||
} else if (isBlizzardShard(projectile.missileType)) {
|
||||
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x: projectile.x,
|
||||
y: projectile.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: projectile.damage,
|
||||
ttl: 6,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: expType,
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
aoeRadius: 32,
|
||||
pierce: false,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -3701,42 +3492,6 @@ export function tickProjectiles(
|
|||
}
|
||||
}
|
||||
|
||||
// 6. Blizzard Center Emitter (pSrvDoFunc = 10, Skill 59)
|
||||
// Authentic D2 1.13c MISSMODE_SrvDo10_blizzardcenter (0x6FC60CA0):
|
||||
// Emits 1 falling shard every 4 ticks (Param3 = 4) across 64-entry circle table (Radius 7)
|
||||
// with step 13, for a total of exactly 25 shards over 100 ticks.
|
||||
if (projectile.missileType === 'blizzardcenter') {
|
||||
const currentAge = (projectile.ageTicks ?? 0) + 1
|
||||
if (currentAge % 4 === 0) {
|
||||
const idx = Math.abs((subMissileIdx ?? 0) % 64)
|
||||
const offset = getBlizzardDropOffset(idx, 7)
|
||||
const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const
|
||||
const shardType = shardVariants[Math.floor((currentAge / 4) - 1) % shardVariants.length]!
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x: projectile.x + offset.dx,
|
||||
y: projectile.y + offset.dy,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: projectile.damage,
|
||||
ttl: 9,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: shardType,
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
pierce: false,
|
||||
})
|
||||
subMissileIdx = (idx + 13) % 64
|
||||
}
|
||||
}
|
||||
|
||||
let altitude = projectile.altitude
|
||||
if (isBlizzardShard(projectile.missileType)) {
|
||||
if (altitude === undefined) altitude = 120
|
||||
altitude = Math.max(0, altitude - (120 / 9))
|
||||
}
|
||||
|
||||
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
||||
const moved: Projectile = {
|
||||
...projectile,
|
||||
|
|
@ -3746,7 +3501,6 @@ export function tickProjectiles(
|
|||
vy,
|
||||
spiral,
|
||||
subMissileIdx,
|
||||
altitude,
|
||||
hitTargets,
|
||||
ageTicks: (projectile.ageTicks ?? 0) + 1,
|
||||
ttl: projectile.ttl - 1,
|
||||
|
|
@ -3788,49 +3542,18 @@ export function tickProjectiles(
|
|||
pierce: false,
|
||||
})
|
||||
}
|
||||
} else if (isBlizzardShard(projectile.missileType)) {
|
||||
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 6,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: expType,
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
aoeRadius: 32,
|
||||
pierce: false,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let struck = false
|
||||
const isHarmlessEmitter =
|
||||
projectile.missileType === 'frozenorb' ||
|
||||
projectile.missileType === 'blizzardcenter' ||
|
||||
isTurretEmitter
|
||||
const isHarmlessEmitter = projectile.missileType === 'frozenorb' || isTurretEmitter
|
||||
if (!isHarmlessEmitter) {
|
||||
for (const target of targets) {
|
||||
if (!target.alive) continue
|
||||
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
||||
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
||||
|
||||
const statusEffect =
|
||||
projectile.statusEffect ??
|
||||
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
|
||||
? 'chill'
|
||||
: undefined)
|
||||
const statusDuration =
|
||||
projectile.statusDuration ??
|
||||
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
|
||||
? 100
|
||||
: undefined)
|
||||
|
||||
hits.push({
|
||||
targetIndex: target.index,
|
||||
damage: projectile.damage,
|
||||
|
|
@ -3839,32 +3562,14 @@ export function tickProjectiles(
|
|||
skillId: projectile.skillId,
|
||||
missileType: projectile.missileType,
|
||||
aoeRadius: projectile.aoeRadius,
|
||||
statusEffect,
|
||||
statusDuration,
|
||||
statusEffect: projectile.statusEffect,
|
||||
statusDuration: projectile.statusDuration,
|
||||
isUndeadOnly: projectile.isUndeadOnly,
|
||||
})
|
||||
|
||||
hitTargets.add(target.index)
|
||||
if (!projectile.pierce) {
|
||||
struck = true
|
||||
if (isBlizzardShard(projectile.missileType)) {
|
||||
const expType = getBlizzardExplosionMissile(projectile.missileType!)
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x,
|
||||
y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 6,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: expType,
|
||||
statusEffect: 'chill',
|
||||
statusDuration: 100,
|
||||
aoeRadius: 32,
|
||||
pierce: false,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -4041,21 +3746,12 @@ export function getSkillCooldownTicks(skillId: number): number {
|
|||
* @param weaponDamage - Optional base weapon damage range { min, max }.
|
||||
*/
|
||||
export function calculateSkillDamage(
|
||||
skillId: number | string,
|
||||
slvl: number,
|
||||
synergies?: number | {
|
||||
readonly iceBolt?: number | undefined
|
||||
readonly iceBlast?: number | undefined
|
||||
readonly glacialSpike?: number | undefined
|
||||
},
|
||||
skillId: number,
|
||||
effectiveLevel: number,
|
||||
synergyMultiplier: number = 1.0,
|
||||
weaponDamage?: { min: number; max: number }
|
||||
): { min: number; max: number } {
|
||||
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
||||
return calculateBlizzardDamage(slvl, synergies)
|
||||
}
|
||||
|
||||
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
|
||||
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
|
||||
const def = getBatch1SkillDef(skillId)
|
||||
if (!def) {
|
||||
if (weaponDamage) {
|
||||
const wMin = Number.isFinite(weaponDamage.min) ? Math.max(0, Math.floor(weaponDamage.min)) : 0
|
||||
|
|
@ -4065,7 +3761,7 @@ export function calculateSkillDamage(
|
|||
return { min: 0, max: 0 }
|
||||
}
|
||||
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
let min = def.emin
|
||||
let max = def.emax
|
||||
|
||||
|
|
@ -4110,7 +3806,7 @@ export function calculateSkillDamage(
|
|||
max *= shiftMultiplier
|
||||
|
||||
// Apply synergy multiplier
|
||||
const syn = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||
const syn = Number.isFinite(synergyMultiplier) && synergyMultiplier > 0 ? synergyMultiplier : 1.0
|
||||
min = Math.floor(min * syn)
|
||||
max = Math.floor(max * syn)
|
||||
|
||||
|
|
@ -4126,71 +3822,6 @@ export function calculateSkillDamage(
|
|||
return { min, max }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Blizzard (Skill 59) synergy multiplier:
|
||||
* +5% damage per hard point in Ice Bolt (39), Ice Blast (45), and Glacial Spike (55).
|
||||
*/
|
||||
export function calculateBlizzardSynergyMultiplier(synergies: {
|
||||
readonly iceBolt?: number | undefined
|
||||
readonly iceBlast?: number | undefined
|
||||
readonly glacialSpike?: number | undefined
|
||||
}): number {
|
||||
const points = (synergies.iceBolt ?? 0) + (synergies.iceBlast ?? 0) + (synergies.glacialSpike ?? 0)
|
||||
return 1.0 + points * 0.05
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Blizzard (Skill 59) min and max damage with 1.13c 5-band level scaling and synergies.
|
||||
*/
|
||||
export function calculateBlizzardDamage(
|
||||
effectiveLevel: number,
|
||||
synergies: {
|
||||
readonly iceBolt?: number | undefined
|
||||
readonly iceBlast?: number | undefined
|
||||
readonly glacialSpike?: number | undefined
|
||||
} | number = {},
|
||||
): { min: number; max: number } {
|
||||
let syn = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||
} else if (synergies && typeof synergies === 'object') {
|
||||
syn = calculateBlizzardSynergyMultiplier(synergies)
|
||||
}
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
let min = 45
|
||||
let max = 75
|
||||
if (lvl > 1) {
|
||||
const b1 = Math.min(lvl - 1, 7)
|
||||
min += b1 * 15
|
||||
max += b1 * 16
|
||||
|
||||
if (lvl > 8) {
|
||||
const b2 = Math.min(lvl - 8, 8)
|
||||
min += b2 * 30
|
||||
max += b2 * 31
|
||||
}
|
||||
if (lvl > 16) {
|
||||
const b3 = Math.min(lvl - 16, 6)
|
||||
min += b3 * 45
|
||||
max += b3 * 46
|
||||
}
|
||||
if (lvl > 22) {
|
||||
const b4 = Math.min(lvl - 22, 6)
|
||||
min += b4 * 55
|
||||
max += b4 * 56
|
||||
}
|
||||
if (lvl > 28) {
|
||||
const b5 = lvl - 28
|
||||
min += b5 * 65
|
||||
max += b5 * 66
|
||||
}
|
||||
}
|
||||
return {
|
||||
min: Math.floor(min * syn),
|
||||
max: Math.floor(max * syn),
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of executing a Batch 1 skill. */
|
||||
export type SkillExecutionResult =
|
||||
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
||||
|
|
|
|||
|
|
@ -885,108 +885,6 @@ export const FROZENORBEXPLODE_META: MissileMeta = {
|
|||
],
|
||||
}
|
||||
|
||||
export const BLIZZARD1_META: MissileMeta = {
|
||||
name: "blizzard1",
|
||||
celFile: "icestormfallvar01",
|
||||
width: 512,
|
||||
height: 198,
|
||||
directions: 1,
|
||||
framesPerDirection: 6,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 56, 198, -50, -208],
|
||||
[57, 0, 56, 198, -50, -208],
|
||||
[114, 0, 56, 198, -50, -208],
|
||||
[171, 0, 56, 198, -50, -208],
|
||||
[228, 0, 56, 198, -50, -208],
|
||||
[285, 0, 56, 198, -50, -208]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const BLIZZARD2_META: MissileMeta = {
|
||||
name: "blizzard2",
|
||||
celFile: "icestormfallvar02",
|
||||
width: 512,
|
||||
height: 195,
|
||||
directions: 1,
|
||||
framesPerDirection: 6,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 48, 195, -46, -201],
|
||||
[49, 0, 48, 195, -46, -201],
|
||||
[98, 0, 48, 195, -46, -201],
|
||||
[147, 0, 48, 195, -46, -201],
|
||||
[196, 0, 48, 195, -46, -201],
|
||||
[245, 0, 48, 195, -46, -201]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const BLIZZARD3_META: MissileMeta = {
|
||||
name: "blizzard3",
|
||||
celFile: "Blizzard",
|
||||
width: 512,
|
||||
height: 307,
|
||||
directions: 1,
|
||||
framesPerDirection: 8,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 97, 153, -71, -153],
|
||||
[98, 0, 97, 153, -71, -153],
|
||||
[196, 0, 97, 153, -71, -153],
|
||||
[294, 0, 97, 153, -71, -153],
|
||||
[392, 0, 97, 153, -71, -153],
|
||||
[0, 154, 97, 153, -71, -153],
|
||||
[98, 154, 97, 153, -71, -153],
|
||||
[196, 154, 97, 153, -71, -153]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const BLIZZARDEXPLODE1_META: MissileMeta = {
|
||||
name: "blizzardexplode1",
|
||||
celFile: "icestormimpactvar01",
|
||||
width: 512,
|
||||
height: 145,
|
||||
directions: 1,
|
||||
framesPerDirection: 6,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 113, 72, -54, -31],
|
||||
[114, 0, 113, 72, -54, -31],
|
||||
[228, 0, 113, 72, -54, -31],
|
||||
[342, 0, 113, 72, -54, -31],
|
||||
[0, 73, 113, 72, -54, -31],
|
||||
[114, 73, 113, 72, -54, -31]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const BLIZZARDEXPLODE2_META: MissileMeta = {
|
||||
name: "blizzardexplode2",
|
||||
celFile: "icestormimpactvar02",
|
||||
width: 512,
|
||||
height: 141,
|
||||
directions: 1,
|
||||
framesPerDirection: 6,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 117, 70, -55, -30],
|
||||
[118, 0, 117, 70, -55, -30],
|
||||
[236, 0, 117, 70, -55, -30],
|
||||
[354, 0, 117, 70, -55, -30],
|
||||
[0, 71, 117, 70, -55, -30],
|
||||
[118, 71, 117, 70, -55, -30]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const CHARGEDBOLT_META: MissileMeta = {
|
||||
name: "chargedbolt",
|
||||
celFile: "ChargedBolt",
|
||||
|
|
@ -4947,16 +4845,6 @@ export const LIGHTNINGJAVELIN_META: MissileMeta = {
|
|||
],
|
||||
}
|
||||
|
||||
export const BLIZZARD4_META: MissileMeta = {
|
||||
...BLIZZARD3_META,
|
||||
name: 'blizzard4',
|
||||
}
|
||||
|
||||
export const BLIZZARDEXPLODE3_META: MissileMeta = {
|
||||
...BLIZZARDEXPLODE2_META,
|
||||
name: 'blizzardexplode3',
|
||||
}
|
||||
|
||||
export const HYDRA_META: MissileMeta = {
|
||||
...FIREBOLT_META,
|
||||
name: 'hydra',
|
||||
|
|
@ -4969,6 +4857,8 @@ export const HYDRAFIRE_META: MissileMeta = {
|
|||
|
||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||
'firebolt': FIREBOLT_META,
|
||||
'hydra': HYDRA_META,
|
||||
'hydrafire': HYDRAFIRE_META,
|
||||
'fireball': FIREBALL_META,
|
||||
'fireexplode': FIREEXPLODE_META,
|
||||
'icebolt': ICEBOLT_META,
|
||||
|
|
@ -4978,11 +4868,6 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
|||
'freezeexplode': FREEZEEXPLODE_META,
|
||||
'frozenorb': FROZENORB_META,
|
||||
'frozenorbexplode': FROZENORBEXPLODE_META,
|
||||
'blizzard1': BLIZZARD1_META,
|
||||
'blizzard2': BLIZZARD2_META,
|
||||
'blizzard3': BLIZZARD3_META,
|
||||
'blizzardexplode1': BLIZZARDEXPLODE1_META,
|
||||
'blizzardexplode2': BLIZZARDEXPLODE2_META,
|
||||
'chargedbolt': CHARGEDBOLT_META,
|
||||
'teeth': TEETH_META,
|
||||
'teethexplode': TEETHEXPLODE_META,
|
||||
|
|
@ -4997,8 +4882,4 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
|||
'icearrow': ICEARROW_META,
|
||||
'arrow': ARROW_META,
|
||||
'lightningjavelin': LIGHTNINGJAVELIN_META,
|
||||
'blizzard4': BLIZZARD4_META,
|
||||
'blizzardexplode3': BLIZZARDEXPLODE3_META,
|
||||
'hydra': HYDRA_META,
|
||||
'hydrafire': HYDRAFIRE_META,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1078,7 +1078,7 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
|||
case 21: return 4
|
||||
case 40: return 7
|
||||
case 42: return 9
|
||||
case 59: return 23
|
||||
case 59: return 27
|
||||
case 60: return 12
|
||||
case 62: return 20
|
||||
case 64: return 30
|
||||
|
|
@ -1297,29 +1297,19 @@ export function castSkill(
|
|||
}
|
||||
|
||||
if (skillId === 59) {
|
||||
// Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates
|
||||
// 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown)
|
||||
// 1.13c Missiles.txt: blizzardcenter (ID 158), Range: 100 ticks (4.0s duration), Vel: 0
|
||||
const slvl = 1
|
||||
const dmgRange = calculateSkillDamage(59, slvl)
|
||||
const blizzardDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||
|
||||
engine.projectiles.push({
|
||||
skillId: '59',
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: blizzardDamage,
|
||||
ttl: 100, // 4.00s at 25 fps
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
})
|
||||
|
||||
// Blizzard: AoE radius 120 around target, delay 45 ticks
|
||||
let hitCount = 0
|
||||
for (const monster of engine.world.monsters) {
|
||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||
if (Math.hypot(monster.x - targetX, monster.y - targetY) <= 120) {
|
||||
damageMonster(engine.world, monster.index, 35)
|
||||
engine.metrics.castHits += 1
|
||||
hitCount++
|
||||
}
|
||||
}
|
||||
player.cooldown = 45
|
||||
engine.metrics.casts += 1
|
||||
status.textContent = '施展暴风雪'
|
||||
status.textContent = `施展暴风雪,命中 ${hitCount} 个目标`
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -3674,12 +3664,6 @@ export async function loadMissileArtMap(
|
|||
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
||||
if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt)
|
||||
}
|
||||
if (map.has('blizzard3') && !map.has('blizzard4')) {
|
||||
map.set('blizzard4', map.get('blizzard3')!)
|
||||
}
|
||||
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
|
||||
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
|
|
@ -3693,12 +3677,6 @@ export function drawMissileProjectile(
|
|||
shot: Projectile,
|
||||
missileArt?: LoadedMissileArt,
|
||||
): void {
|
||||
if (shot.missileType === 'blizzardcenter') {
|
||||
return // Invisible controller missile
|
||||
}
|
||||
|
||||
const altitude = shot.altitude ?? 0
|
||||
|
||||
if (missileArt !== undefined) {
|
||||
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
||||
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
||||
|
|
@ -3707,7 +3685,7 @@ export function drawMissileProjectile(
|
|||
const frame = group?.[frameIndex] ?? group?.[0]
|
||||
if (frame !== undefined) {
|
||||
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
|
||||
const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude
|
||||
const drawY = frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2
|
||||
const isAdditive = missileArt.meta.name !== 'arrow'
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: missileArt.handle,
|
||||
|
|
@ -3718,7 +3696,7 @@ export function drawMissileProjectile(
|
|||
}
|
||||
|
||||
const x = shot.x
|
||||
const y = shot.y - altitude
|
||||
const y = shot.y
|
||||
|
||||
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
||||
// Authentic stationary hydra head turret plume
|
||||
|
|
@ -5278,15 +5256,9 @@ if (typeof window !== 'undefined') {
|
|||
}
|
||||
|
||||
for (const shot of engine.projectiles) {
|
||||
if (shot.missileType === 'blizzardcenter') continue
|
||||
pushEntity(shot.x, shot.y, () => {
|
||||
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
|
||||
const art = mType !== undefined
|
||||
? (missileArtMap.get(mType) ??
|
||||
(mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') :
|
||||
mType === 'blizzard4' ? missileArtMap.get('blizzard3') :
|
||||
mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : undefined))
|
||||
: undefined
|
||||
const art = mType !== undefined ? (missileArtMap.get(mType) ?? (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : undefined)) : undefined
|
||||
if (art !== undefined) {
|
||||
drawMissileProjectile(renderer, shot, art)
|
||||
} else {
|
||||
|
|
@ -5297,9 +5269,7 @@ if (typeof window !== 'undefined') {
|
|||
|
||||
for (const exp of engine.explosions) {
|
||||
pushEntity(exp.x, exp.y, () => {
|
||||
const art =
|
||||
missileArtMap.get(exp.missileType) ??
|
||||
(exp.missileType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : missileArtMap.get('fireexplode'))
|
||||
const art = missileArtMap.get(exp.missileType) ?? missileArtMap.get('fireexplode')
|
||||
drawExplosion(renderer, exp, art)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,506 +0,0 @@
|
|||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Skill #059: Blizzard (SOR)
|
||||
* Missile Physics, 64-Entry Circle Emitter, Falling Altitude & 1.13c Parity Suite
|
||||
*
|
||||
* Requirements (Issue #400 / Blizzard Parity):
|
||||
* 1. Missiles.txt ground truth: parses and verifies blizzardcenter (ID 158), blizzard1..4 (IDs 159..162),
|
||||
* and blizzardexplode1..3 (IDs 163..165).
|
||||
* 2. Visual assets & atlas metadata: BLIZZARD1..4_META (icestormfallvar01/02 and Blizzard.dcc),
|
||||
* BLIZZARDEXPLODE1..3_META (icestormimpactvar01/02.dcc), baked PNG/JSON files exist.
|
||||
* 3. Authoritative 64-entry circle table from D2Game.dll (VA 0x6FD1C960, 0x6FD1CA60),
|
||||
* 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), step 13 coprime rotation.
|
||||
* 4. Controller missile blizzardcenter (pSrvDoFunc = 10): lives 100 ticks, emits 25 shards total
|
||||
* (every 4 ticks), harmless emitter (no direct contact damage).
|
||||
* 5. Falling shard vertical descent: altitude drops from 120 px to 0 over 9 ticks (120/9 px/tick).
|
||||
* 6. Ground impact & wall detonation: spawns blizzardexplode1..3 upon expiry or obstacle collision.
|
||||
* 7. NextDelay = 0 sweet-spot burst: multiple overlapping shards deal full damage without immunity windows.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
parseMissilesTxt,
|
||||
getMissileTxtData,
|
||||
CANONICAL_113C_MISSILES,
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
calculateBlizzardDamage,
|
||||
calculateBlizzardSynergyMultiplier,
|
||||
type Projectile,
|
||||
} from '../src/game/skills.ts'
|
||||
import {
|
||||
BLIZZARD_CIRCLE_X,
|
||||
BLIZZARD_CIRCLE_Y,
|
||||
getBlizzardDropOffset,
|
||||
} from '../src/game/engine/blizzard-table.ts'
|
||||
import {
|
||||
BLIZZARD1_META,
|
||||
BLIZZARD2_META,
|
||||
BLIZZARD3_META,
|
||||
BLIZZARD4_META,
|
||||
BLIZZARDEXPLODE1_META,
|
||||
BLIZZARDEXPLODE2_META,
|
||||
BLIZZARDEXPLODE3_META,
|
||||
MISSILE_METAS,
|
||||
} from '../src/render/missiles-meta.ts'
|
||||
|
||||
describe('[Skill #059] Blizzard — 1.13c Missile Physics & Runtime Parity (Issue #400)', () => {
|
||||
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
||||
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||
|
||||
it('1. Parses authentic 1.13c Missiles.txt and verifies Blizzard missile suite (IDs 158..165)', () => {
|
||||
const map = parseMissilesTxt(missilesTxtContent)
|
||||
|
||||
// blizzardcenter (ID 158)
|
||||
const center = map.get('blizzardcenter')
|
||||
expect(center).toBeDefined()
|
||||
expect(center!.id).toBe(158)
|
||||
expect(center!.vel).toBe(0)
|
||||
expect(center!.range).toBe(100)
|
||||
expect(center!.animLen).toBe(2)
|
||||
expect(center!.red).toBe(81)
|
||||
expect(center!.green).toBe(81)
|
||||
expect(center!.blue).toBe(255)
|
||||
|
||||
// blizzard1 (ID 159)
|
||||
const b1 = map.get('blizzard1')
|
||||
expect(b1).toBeDefined()
|
||||
expect(b1!.id).toBe(159)
|
||||
expect(b1!.vel).toBe(0)
|
||||
expect(b1!.range).toBe(9)
|
||||
expect(b1!.celFile.toLowerCase()).toBe('icestormfallvar01')
|
||||
expect(b1!.animLen).toBe(6)
|
||||
|
||||
// blizzard2 (ID 160)
|
||||
const b2 = map.get('blizzard2')
|
||||
expect(b2).toBeDefined()
|
||||
expect(b2!.id).toBe(160)
|
||||
expect(b2!.vel).toBe(0)
|
||||
expect(b2!.range).toBe(9)
|
||||
expect(b2!.celFile.toLowerCase()).toBe('icestormfallvar02')
|
||||
expect(b2!.animLen).toBe(6)
|
||||
|
||||
// blizzard3 (ID 161)
|
||||
const b3 = map.get('blizzard3')
|
||||
expect(b3).toBeDefined()
|
||||
expect(b3!.id).toBe(161)
|
||||
expect(b3!.vel).toBe(0)
|
||||
expect(b3!.range).toBe(9)
|
||||
expect(b3!.celFile.toLowerCase()).toBe('blizzard')
|
||||
expect(b3!.animLen).toBe(8)
|
||||
|
||||
// blizzard4 (ID 162)
|
||||
const b4 = map.get('blizzard4')
|
||||
expect(b4).toBeDefined()
|
||||
expect(b4!.id).toBe(162)
|
||||
expect(b4!.vel).toBe(0)
|
||||
expect(b4!.range).toBe(9)
|
||||
expect(b4!.celFile.toLowerCase()).toBe('blizzard')
|
||||
expect(b4!.animLen).toBe(8)
|
||||
|
||||
// blizzardexplode1 (ID 163)
|
||||
const exp1 = map.get('blizzardexplode1')
|
||||
expect(exp1).toBeDefined()
|
||||
expect(exp1!.id).toBe(163)
|
||||
expect(exp1!.vel).toBe(0)
|
||||
expect(exp1!.range).toBe(6)
|
||||
expect(exp1!.celFile.toLowerCase()).toBe('icestormimpactvar01')
|
||||
expect(exp1!.animLen).toBe(6)
|
||||
|
||||
// blizzardexplode2 (ID 164)
|
||||
const exp2 = map.get('blizzardexplode2')
|
||||
expect(exp2).toBeDefined()
|
||||
expect(exp2!.id).toBe(164)
|
||||
expect(exp2!.vel).toBe(0)
|
||||
expect(exp2!.range).toBe(6)
|
||||
expect(exp2!.celFile.toLowerCase()).toBe('icestormimpactvar02')
|
||||
expect(exp2!.animLen).toBe(6)
|
||||
|
||||
// blizzardexplode3 (ID 165)
|
||||
const exp3 = map.get('blizzardexplode3')
|
||||
expect(exp3).toBeDefined()
|
||||
expect(exp3!.id).toBe(165)
|
||||
expect(exp3!.vel).toBe(0)
|
||||
expect(exp3!.range).toBe(6)
|
||||
expect(exp3!.celFile.toLowerCase()).toBe('icestormimpactvar02')
|
||||
expect(exp3!.animLen).toBe(6)
|
||||
})
|
||||
|
||||
it('2. CANONICAL_113C_MISSILES export contains Blizzard suite with exact 1.13c values', () => {
|
||||
const center = getMissileTxtData('blizzardcenter')
|
||||
expect(center.id).toBe(158)
|
||||
expect(center.vel).toBe(0)
|
||||
expect(center.range).toBe(100)
|
||||
|
||||
const b1 = getMissileTxtData('blizzard1')
|
||||
expect(b1.id).toBe(159)
|
||||
expect(b1.vel).toBe(0)
|
||||
expect(b1.range).toBe(9)
|
||||
expect(b1.animLen).toBe(6)
|
||||
expect(b1.explosionMissile).toBe('blizzardexplode1')
|
||||
|
||||
const b2 = getMissileTxtData('blizzard2')
|
||||
expect(b2.id).toBe(160)
|
||||
expect(b2.range).toBe(9)
|
||||
expect(b2.explosionMissile).toBe('blizzardexplode2')
|
||||
|
||||
const b3 = getMissileTxtData('blizzard3')
|
||||
expect(b3.id).toBe(161)
|
||||
expect(b3.range).toBe(9)
|
||||
expect(b3.animLen).toBe(8)
|
||||
expect(b3.explosionMissile).toBe('blizzardexplode3')
|
||||
|
||||
const b4 = getMissileTxtData('blizzard4')
|
||||
expect(b4.id).toBe(162)
|
||||
expect(b4.range).toBe(9)
|
||||
expect(b4.animLen).toBe(8)
|
||||
expect(b4.explosionMissile).toBe('blizzardexplode1')
|
||||
|
||||
const exp1 = getMissileTxtData('blizzardexplode1')
|
||||
expect(exp1.id).toBe(163)
|
||||
expect(exp1.range).toBe(6)
|
||||
|
||||
const exp2 = getMissileTxtData('blizzardexplode2')
|
||||
expect(exp2.id).toBe(164)
|
||||
expect(exp2.range).toBe(6)
|
||||
|
||||
const exp3 = getMissileTxtData('blizzardexplode3')
|
||||
expect(exp3.id).toBe(165)
|
||||
expect(exp3.range).toBe(6)
|
||||
})
|
||||
|
||||
it('3. Authentic baked DCC atlas metadata and files exist on disk with non-zero byte size', () => {
|
||||
expect(BLIZZARD1_META.name).toBe('blizzard1')
|
||||
expect(BLIZZARD1_META.celFile).toBe('icestormfallvar01')
|
||||
expect(BLIZZARD1_META.framesPerDirection).toBe(6)
|
||||
|
||||
expect(BLIZZARD2_META.name).toBe('blizzard2')
|
||||
expect(BLIZZARD2_META.celFile).toBe('icestormfallvar02')
|
||||
expect(BLIZZARD2_META.framesPerDirection).toBe(6)
|
||||
|
||||
expect(BLIZZARD3_META.name).toBe('blizzard3')
|
||||
expect(BLIZZARD3_META.celFile).toBe('Blizzard')
|
||||
expect(BLIZZARD3_META.framesPerDirection).toBe(8)
|
||||
|
||||
expect(BLIZZARD4_META.name).toBe('blizzard4')
|
||||
expect(BLIZZARD4_META.celFile).toBe('Blizzard')
|
||||
expect(BLIZZARD4_META.framesPerDirection).toBe(8)
|
||||
|
||||
expect(BLIZZARDEXPLODE1_META.name).toBe('blizzardexplode1')
|
||||
expect(BLIZZARDEXPLODE1_META.celFile).toBe('icestormimpactvar01')
|
||||
expect(BLIZZARDEXPLODE1_META.framesPerDirection).toBe(6)
|
||||
|
||||
expect(BLIZZARDEXPLODE2_META.name).toBe('blizzardexplode2')
|
||||
expect(BLIZZARDEXPLODE2_META.celFile).toBe('icestormimpactvar02')
|
||||
expect(BLIZZARDEXPLODE2_META.framesPerDirection).toBe(6)
|
||||
|
||||
expect(BLIZZARDEXPLODE3_META.name).toBe('blizzardexplode3')
|
||||
expect(BLIZZARDEXPLODE3_META.celFile).toBe('icestormimpactvar02')
|
||||
expect(BLIZZARDEXPLODE3_META.framesPerDirection).toBe(6)
|
||||
|
||||
expect(MISSILE_METAS['blizzard1']).toBe(BLIZZARD1_META)
|
||||
expect(MISSILE_METAS['blizzard2']).toBe(BLIZZARD2_META)
|
||||
expect(MISSILE_METAS['blizzard3']).toBe(BLIZZARD3_META)
|
||||
expect(MISSILE_METAS['blizzard4']).toBe(BLIZZARD4_META)
|
||||
expect(MISSILE_METAS['blizzardexplode1']).toBe(BLIZZARDEXPLODE1_META)
|
||||
expect(MISSILE_METAS['blizzardexplode2']).toBe(BLIZZARDEXPLODE2_META)
|
||||
expect(MISSILE_METAS['blizzardexplode3']).toBe(BLIZZARDEXPLODE3_META)
|
||||
|
||||
const missileFiles = [
|
||||
'blizzard1',
|
||||
'blizzard2',
|
||||
'blizzard3',
|
||||
'blizzard4',
|
||||
'blizzardexplode1',
|
||||
'blizzardexplode2',
|
||||
'blizzardexplode3',
|
||||
]
|
||||
|
||||
for (const name of missileFiles) {
|
||||
const pngPath = path.resolve(__dirname, `../public/missiles/${name}.png`)
|
||||
const jsonPath = path.resolve(__dirname, `../public/missiles/${name}.json`)
|
||||
expect(fs.existsSync(pngPath), `Missing ${pngPath}`).toBe(true)
|
||||
expect(fs.statSync(pngPath).size).toBeGreaterThan(1000)
|
||||
expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
|
||||
expect(fs.statSync(jsonPath).size).toBeGreaterThan(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('4. Authoritative 64-entry circle lookup table matches 1.13c binary assembly (radius 30, Y*0.5)', () => {
|
||||
expect(BLIZZARD_CIRCLE_X.length).toBe(64)
|
||||
expect(BLIZZARD_CIRCLE_Y.length).toBe(64)
|
||||
|
||||
// Primary cardinal points
|
||||
expect(BLIZZARD_CIRCLE_X[0]).toBe(0)
|
||||
expect(BLIZZARD_CIRCLE_Y[0]).toBe(30)
|
||||
expect(BLIZZARD_CIRCLE_X[16]).toBe(30)
|
||||
expect(BLIZZARD_CIRCLE_Y[16]).toBe(0)
|
||||
expect(BLIZZARD_CIRCLE_X[32]).toBe(0)
|
||||
expect(BLIZZARD_CIRCLE_Y[32]).toBe(-30)
|
||||
expect(BLIZZARD_CIRCLE_X[48]).toBe(-30)
|
||||
expect(BLIZZARD_CIRCLE_Y[48]).toBe(0)
|
||||
|
||||
// getBlizzardDropOffset checks
|
||||
// At index 16 (X=30, Y=0), radius 7 subtiles: dx = 30 * (112/30) = 112, dy = 0
|
||||
const offEast = getBlizzardDropOffset(16, 7, 16)
|
||||
expect(offEast.dx).toBe(112)
|
||||
expect(offEast.dy).toBe(0)
|
||||
|
||||
// At index 0 (X=0, Y=30), radius 7 subtiles: dx = 0, dy = 30 * (112/30) * 0.5 = 56
|
||||
const offSouth = getBlizzardDropOffset(0, 7, 16)
|
||||
expect(offSouth.dx).toBe(0)
|
||||
expect(offSouth.dy).toBe(56)
|
||||
|
||||
// 2:1 isometric aspect ratio verification
|
||||
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
|
||||
})
|
||||
|
||||
it('5. tickProjectiles: blizzardcenter lives 100 ticks and emits exactly 25 shards (cadence = 4 ticks, step = 13)', () => {
|
||||
let center: Projectile = {
|
||||
skillId: '59',
|
||||
x: 300,
|
||||
y: 300,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 150,
|
||||
ttl: 100, // 100 frames = 4.0s
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
}
|
||||
|
||||
const allEmittedShards: Projectile[] = []
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
for (let tick = 1; tick <= 100; tick += 1) {
|
||||
const outcome = tickProjectiles([center], [], openTerrain)
|
||||
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.damage).toBe(150)
|
||||
expect(shard.statusEffect).toBe('chill')
|
||||
expect(shard.statusDuration).toBe(100)
|
||||
expect(shard.ttl).toBe(9)
|
||||
expect(shard.altitude).toBe(120)
|
||||
allEmittedShards.push(shard)
|
||||
} else {
|
||||
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
|
||||
}
|
||||
|
||||
// Center must remain alive until tick 100
|
||||
center = outcome.alive.find(p => p.missileType === 'blizzardcenter')!
|
||||
if (tick < 100) {
|
||||
expect(center).toBeDefined()
|
||||
expect(center.ttl).toBe(100 - tick)
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly 25 shards produced over 100 ticks (100 / 4)
|
||||
expect(allEmittedShards.length).toBe(25)
|
||||
|
||||
// At tick 101, center expires
|
||||
const expiredOutcome = tickProjectiles([center], [], openTerrain)
|
||||
expect(expiredOutcome.expired).toBe(1)
|
||||
expect(expiredOutcome.alive.length).toBe(0)
|
||||
})
|
||||
|
||||
it('6. Main blizzardcenter is a harmless emitter and pierces monsters without dealing direct contact damage', () => {
|
||||
const center: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 20,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
}
|
||||
|
||||
const monster = [{ index: 0, x: 200, y: 200, radius: 25, alive: true }]
|
||||
const outcome = tickProjectiles([center], monster, { overlap: () => 0 })
|
||||
|
||||
// Center must not trigger hits or take damage on the monster
|
||||
expect(outcome.hits.length).toBe(0)
|
||||
expect(outcome.alive.some(p => p.missileType === 'blizzardcenter')).toBe(true)
|
||||
})
|
||||
|
||||
it('7. Falling shard altitude descends vertically from 120 px to 0 over 9 ticks and detonates on ground impact', () => {
|
||||
let shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 150,
|
||||
y: 150,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: 80,
|
||||
ttl: 9,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
for (let tick = 1; tick <= 9; tick += 1) {
|
||||
const outcome = tickProjectiles([shard], [], openTerrain)
|
||||
shard = outcome.alive.find(p => p.missileType === 'blizzard1')!
|
||||
if (tick < 9) {
|
||||
expect(shard).toBeDefined()
|
||||
expect(shard.ttl).toBe(9 - tick)
|
||||
// Decrements by 120 / 9 px per tick
|
||||
const expectedAltitude = 120 - tick * (120 / 9)
|
||||
expect(shard.altitude).toBeCloseTo(expectedAltitude, 0.1)
|
||||
} else {
|
||||
// At tick 9, ttl reached 0
|
||||
expect(shard.ttl).toBe(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Next tick: shard with ttl 0 expires and detonates ground impact explosion
|
||||
const impactOutcome = tickProjectiles([shard], [], openTerrain)
|
||||
expect(impactOutcome.expired).toBe(1)
|
||||
expect(impactOutcome.spawnedProjectiles).toBeDefined()
|
||||
expect(impactOutcome.spawnedProjectiles!.length).toBe(1)
|
||||
|
||||
const exp = impactOutcome.spawnedProjectiles![0]!
|
||||
expect(exp.missileType).toBe('blizzardexplode1')
|
||||
expect(exp.damage).toBe(80)
|
||||
expect(exp.ttl).toBe(6) // 1.13c Range = 6
|
||||
expect(exp.statusEffect).toBe('chill')
|
||||
expect(exp.statusDuration).toBe(100) // 100 ticks chill length
|
||||
})
|
||||
|
||||
it('8. Wall collision halts descending shard and spawns impact explosion', () => {
|
||||
const shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 80,
|
||||
damage: 90,
|
||||
ttl: 7,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard2',
|
||||
}
|
||||
|
||||
// Solid wall at (100, 100)
|
||||
const wallTerrain = { overlap: () => 1 }
|
||||
const outcome = tickProjectiles([shard], [], wallTerrain)
|
||||
|
||||
expect(outcome.wallHits).toBe(1)
|
||||
expect(outcome.alive.some(p => p.missileType === 'blizzard2')).toBe(false)
|
||||
expect(outcome.spawnedProjectiles).toBeDefined()
|
||||
expect(outcome.spawnedProjectiles!.length).toBe(1)
|
||||
|
||||
const exp = outcome.spawnedProjectiles![0]!
|
||||
expect(exp.missileType).toBe('blizzardexplode2')
|
||||
expect(exp.ttl).toBe(6)
|
||||
})
|
||||
|
||||
it('9. Direct monster hit while falling deals cold damage and chill without immunity window (NextDelay = 0)', () => {
|
||||
const shard1: Projectile = {
|
||||
skillId: '59',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 5,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
const shard2: Projectile = {
|
||||
skillId: '59',
|
||||
x: 102,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 5,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard2',
|
||||
}
|
||||
|
||||
const dummyMonster = [{ index: 1, x: 101, y: 100, radius: 12, alive: true }]
|
||||
const outcome = tickProjectiles([shard1, shard2], dummyMonster, { overlap: () => 0 })
|
||||
|
||||
// NextDelay = 0 allows both shards to hit the monster in the same frame
|
||||
expect(outcome.hits.length).toBe(2)
|
||||
for (const hit of outcome.hits) {
|
||||
expect(hit.targetIndex).toBe(1)
|
||||
expect(hit.damage).toBe(100)
|
||||
expect(hit.statusEffect).toBe('chill')
|
||||
expect(hit.statusDuration).toBe(100)
|
||||
}
|
||||
|
||||
// Both shards must trigger explosion
|
||||
expect(outcome.spawnedProjectiles!.length).toBe(2)
|
||||
expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode1')).toBe(true)
|
||||
expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode2')).toBe(true)
|
||||
})
|
||||
|
||||
it('10. calculateBlizzardDamage and calculateBlizzardSynergyMultiplier evaluate 5-band formulas & synergies', () => {
|
||||
// slvl 1: 45 - 75
|
||||
const d1 = calculateBlizzardDamage(1)
|
||||
expect(d1.min).toBe(45)
|
||||
expect(d1.max).toBe(75)
|
||||
|
||||
// slvl 10: 210 - 249
|
||||
const d10 = calculateBlizzardDamage(10)
|
||||
expect(d10.min).toBe(210)
|
||||
expect(d10.max).toBe(249)
|
||||
|
||||
// slvl 20: 570 - 619
|
||||
const d20 = calculateBlizzardDamage(20)
|
||||
expect(d20.min).toBe(570)
|
||||
expect(d20.max).toBe(619)
|
||||
|
||||
// slvl 30: 1120 - 1179
|
||||
const d30 = calculateBlizzardDamage(30)
|
||||
expect(d30.min).toBe(1120)
|
||||
expect(d30.max).toBe(1179)
|
||||
|
||||
// Synergy: 20 points in Ice Bolt (+100% = 2.0x)
|
||||
const syn20 = calculateBlizzardSynergyMultiplier({ iceBolt: 20 })
|
||||
expect(syn20).toBe(2.0)
|
||||
const d20Syn = calculateBlizzardDamage(20, { iceBolt: 20 })
|
||||
expect(d20Syn.min).toBe(1140) // 570 * 2.0
|
||||
expect(d20Syn.max).toBe(1238) // 619 * 2.0
|
||||
|
||||
// Full synergies: 20 in Ice Bolt, 20 in Ice Blast, 20 in Glacial Spike (+300% = 4.0x)
|
||||
const syn60 = calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20, glacialSpike: 20 })
|
||||
expect(syn60).toBe(4.0)
|
||||
const d20Full = calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20, glacialSpike: 20 })
|
||||
expect(d20Full.min).toBe(2280) // 570 * 4.0
|
||||
expect(d20Full.max).toBe(2476) // 619 * 4.0
|
||||
})
|
||||
})
|
||||
|
|
@ -1,636 +0,0 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Skill #059: Blizzard
|
||||
* Empirical Stress Testing & Adversarial Verification Suite
|
||||
*
|
||||
* Requirements (User Request & Solution Stress Testing Playbook):
|
||||
* 1. Altitude descent: 120 px to 0 px over 9 ticks (linear 120/9 px/tick, clamped at 0).
|
||||
* 2. Ground impact explosion at tick 9 vs immediate explosion on monster collision at tick t < 9.
|
||||
* 3. NextDelay = 0: multiple shards hitting the same target deal damage without cooldown immunity
|
||||
* (both simultaneous hits and consecutive tick hits, with differential oracle contrast).
|
||||
* 4. Wall collisions: shards hitting walls explode and terminate cleanly without memory leak or hanging.
|
||||
* 5. 100,000 tick continuous stress simulation: verifying stability, bounded active missile count,
|
||||
* zero NaNs, zero crashes, and zero memory leaks over 2,222+ Blizzard casts.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
calculateBlizzardDamage,
|
||||
calculateBlizzardSynergyMultiplier,
|
||||
CANONICAL_113C_MISSILES,
|
||||
type Projectile,
|
||||
type ProjectileTarget,
|
||||
} from '../src/game/skills.ts'
|
||||
import {
|
||||
BLIZZARD_CIRCLE_X,
|
||||
BLIZZARD_CIRCLE_Y,
|
||||
getBlizzardDropOffset,
|
||||
} from '../src/game/engine/blizzard-table.ts'
|
||||
import { MissileEngine } from '../src/game/engine/missile-engine.ts'
|
||||
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } 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 { drawMissileProjectile } from '../src/scene/act-scene.ts'
|
||||
|
||||
describe('[Skill #059] Blizzard — Empirical Stress & Physics Challenger Suite', () => {
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
const wallTerrain = { overlap: () => 1 }
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 1: Shard Altitude Descent Physics (120 px -> 0 px over 9 ticks)
|
||||
// =========================================================================
|
||||
describe('Dimension 1: Shard Altitude Descent Physics (120 px -> 0 px over 9 ticks)', () => {
|
||||
const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const
|
||||
|
||||
for (const variant of shardVariants) {
|
||||
it(`verifies altitude descent of ${variant} tick-by-tick from 120 px to 0 px over exactly 9 ticks`, () => {
|
||||
let shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: 100,
|
||||
ttl: 9,
|
||||
fromPlayer: true,
|
||||
missileType: variant,
|
||||
}
|
||||
|
||||
const descentRate = 120 / 9 // 13.333333333333334 px/tick
|
||||
|
||||
for (let tick = 1; tick <= 9; tick++) {
|
||||
const outcome = tickProjectiles([shard], [], openTerrain)
|
||||
shard = outcome.alive.find(p => p.missileType === variant)!
|
||||
|
||||
expect(shard).toBeDefined()
|
||||
expect(shard.ttl).toBe(9 - tick)
|
||||
|
||||
const expectedAltitude = Math.max(0, 120 - tick * descentRate)
|
||||
expect(shard.altitude).toBeCloseTo(expectedAltitude, 0.001)
|
||||
|
||||
if (tick === 9) {
|
||||
// At tick 9, altitude reaches ground level (within IEEE-754 float precision ~1e-14)
|
||||
expect(shard.altitude).toBeCloseTo(0, 5)
|
||||
expect(shard.ttl).toBe(0)
|
||||
} else {
|
||||
expect(shard.altitude).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('verifies altitude never drops below zero and remains non-negative even if ticked past 0', () => {
|
||||
let shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 5, // very close to ground
|
||||
damage: 100,
|
||||
ttl: 5,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
|
||||
// Tick once: 5 - (120/9) < 0, must be clamped to exactly 0
|
||||
const outcome = tickProjectiles([shard], [], openTerrain)
|
||||
const ticked = outcome.alive.find(p => p.missileType === 'blizzard1')!
|
||||
expect(ticked.altitude).toBe(0)
|
||||
expect(Number.isNaN(ticked.altitude!)).toBe(false)
|
||||
expect(Number.isFinite(ticked.altitude!)).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies visual screen projection offset: drawY = WorldY - altitude', () => {
|
||||
const drawnSprites: { x: number; y: number }[] = []
|
||||
const mockRenderer = {
|
||||
draw: (_frame: any, x: number, y: number) => {
|
||||
drawnSprites.push({ x, y })
|
||||
},
|
||||
drawSolid: (_x: number, _y: number) => {},
|
||||
} as any
|
||||
|
||||
const mockArt = {
|
||||
meta: { name: 'blizzard1', directions: 1, framesPerDirection: 6, animSpeed: 16 },
|
||||
frames: { 0: [{ anchorX: 0, anchorY: 0, width: 32, height: 64 }] },
|
||||
handle: 'atlas',
|
||||
} as any
|
||||
|
||||
// Shard at World (200, 200), altitude 120 -> screen Y should be 200 - 120 = 80
|
||||
const shardHigh: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: 100,
|
||||
ttl: 9,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
|
||||
drawMissileProjectile(mockRenderer, shardHigh, mockArt)
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnSprites[0]!.y).toBe(200 - 120) // 80 px
|
||||
|
||||
// Shard at World (200, 200), altitude 0 (ground level) -> screen Y should be 200
|
||||
drawnSprites.length = 0
|
||||
const shardGround: Projectile = {
|
||||
...shardHigh,
|
||||
altitude: 0,
|
||||
ttl: 0,
|
||||
}
|
||||
drawMissileProjectile(mockRenderer, shardGround, mockArt)
|
||||
expect(drawnSprites.length).toBe(1)
|
||||
expect(drawnSprites[0]!.y).toBe(200) // 200 px
|
||||
})
|
||||
|
||||
it('verifies all 25 shards spawned from blizzardcenter initialize with altitude 120 px', () => {
|
||||
const center: Projectile = {
|
||||
skillId: '59',
|
||||
x: 300,
|
||||
y: 300,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 200,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
}
|
||||
|
||||
let current: Projectile[] = [center]
|
||||
const spawnedShards: Projectile[] = []
|
||||
|
||||
for (let t = 1; t <= 100; t++) {
|
||||
const res = tickProjectiles(current, [], openTerrain)
|
||||
current = res.alive as Projectile[]
|
||||
if (res.spawnedProjectiles && res.spawnedProjectiles.length > 0) {
|
||||
for (const p of res.spawnedProjectiles) {
|
||||
if (p.missileType?.startsWith('blizzard') && !p.missileType.includes('explode')) {
|
||||
spawnedShards.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnedShards.length).toBe(25)
|
||||
for (const shard of spawnedShards) {
|
||||
expect(shard.altitude).toBe(120)
|
||||
expect(shard.ttl).toBe(9)
|
||||
expect(shard.damage).toBe(200)
|
||||
expect(shard.statusEffect).toBe('chill')
|
||||
expect(shard.statusDuration).toBe(100)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 2: Ground Impact Explosion (t = 9) vs Immediate Collision (t < 9)
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Ground Impact Explosion (t = 9) vs Immediate Collision (t < 9)', () => {
|
||||
it('Case A (Ground Impact): shard reaches altitude 0 at tick 9 and detonates ground impact explosion on expiry', () => {
|
||||
let shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 250,
|
||||
y: 250,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: 150,
|
||||
ttl: 9,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
|
||||
// Over ticks 1..9, no monster is present
|
||||
for (let tick = 1; tick <= 9; tick++) {
|
||||
const res = tickProjectiles([shard], [], openTerrain)
|
||||
expect(res.hits.length).toBe(0)
|
||||
expect(res.expired).toBe(0)
|
||||
shard = res.alive.find(p => p.missileType === 'blizzard1')!
|
||||
expect(shard).toBeDefined()
|
||||
}
|
||||
|
||||
// At tick 9, shard is at ground level (altitude ~0 within IEEE-754 precision, ttl 0)
|
||||
expect(shard.altitude).toBeCloseTo(0, 5)
|
||||
expect(shard.ttl).toBe(0)
|
||||
|
||||
// On next tick (tick 10), shard expires and detonates ground explosion
|
||||
const detonateRes = tickProjectiles([shard], [], openTerrain)
|
||||
expect(detonateRes.expired).toBe(1)
|
||||
expect(detonateRes.alive.some(p => p.missileType === 'blizzard1')).toBe(false)
|
||||
expect(detonateRes.spawnedProjectiles).toBeDefined()
|
||||
expect(detonateRes.spawnedProjectiles!.length).toBe(1)
|
||||
|
||||
const exp = detonateRes.spawnedProjectiles![0]!
|
||||
expect(exp.missileType).toBe('blizzardexplode1')
|
||||
expect(exp.ttl).toBe(6) // 1.13c Range = 6
|
||||
expect(exp.statusEffect).toBe('chill')
|
||||
expect(exp.statusDuration).toBe(100) // 1.13c ElemLen = 100
|
||||
expect(exp.x).toBe(250)
|
||||
expect(exp.y).toBe(250)
|
||||
})
|
||||
|
||||
it('Case B (Immediate Monster Collision at t < 9): parametric sweep across all ticks t in [1..8]', () => {
|
||||
// Test collision at every possible mid-air tick: t = 1, 2, 3, 4, 5, 6, 7, 8
|
||||
for (let collisionTick = 1; collisionTick <= 8; collisionTick++) {
|
||||
let shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: 120,
|
||||
ttl: 9,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard2',
|
||||
}
|
||||
|
||||
// Ticks before collision: target is distant/inactive
|
||||
const distantTarget: ProjectileTarget = { index: 1, x: 9999, y: 9999, radius: 20, alive: true }
|
||||
for (let t = 1; t < collisionTick; t++) {
|
||||
const res = tickProjectiles([shard], [distantTarget], openTerrain)
|
||||
expect(res.hits.length).toBe(0)
|
||||
shard = res.alive.find(p => p.missileType === 'blizzard2')!
|
||||
expect(shard).toBeDefined()
|
||||
expect(shard.altitude).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// At collisionTick, place target directly under the falling shard
|
||||
const closeTarget: ProjectileTarget = { index: 1, x: 200, y: 200, radius: 20, alive: true }
|
||||
const hitRes = tickProjectiles([shard], [closeTarget], openTerrain)
|
||||
|
||||
// 1. Hit must register IMMEDIATELY at collisionTick
|
||||
expect(hitRes.hits.length).toBe(1)
|
||||
const hit = hitRes.hits[0]!
|
||||
expect(hit.targetIndex).toBe(1)
|
||||
expect(hit.damage).toBe(120)
|
||||
expect(hit.statusEffect).toBe('chill')
|
||||
expect(hit.statusDuration).toBe(100)
|
||||
|
||||
// 2. Shard must be destroyed IMMEDIATELY on collision (not survive to tick 9)
|
||||
expect(hitRes.alive.some(p => p.missileType === 'blizzard2')).toBe(false)
|
||||
|
||||
// 3. Impact explosion must be spawned IMMEDIATELY at collisionTick
|
||||
expect(hitRes.spawnedProjectiles).toBeDefined()
|
||||
expect(hitRes.spawnedProjectiles!.length).toBe(1)
|
||||
const exp = hitRes.spawnedProjectiles![0]!
|
||||
expect(exp.missileType).toBe('blizzardexplode2')
|
||||
expect(exp.ttl).toBe(6)
|
||||
expect(exp.x).toBe(200)
|
||||
expect(exp.y).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('empirical finding: blizzardexplode spawned on monster hit carries damage: 0 to prevent double damage', () => {
|
||||
const shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 60,
|
||||
damage: 150,
|
||||
ttl: 4,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard3',
|
||||
}
|
||||
|
||||
const target: ProjectileTarget = { index: 1, x: 200, y: 200, radius: 20, alive: true }
|
||||
const res = tickProjectiles([shard], [target], openTerrain)
|
||||
|
||||
expect(res.hits.length).toBe(1)
|
||||
expect(res.hits[0]!.damage).toBe(150) // shard dealt full damage
|
||||
|
||||
// Spawned explosion must have damage = 0
|
||||
const exp = res.spawnedProjectiles![0]!
|
||||
expect(exp.missileType).toBe('blizzardexplode3')
|
||||
expect(exp.damage).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 3: NextDelay = 0 Invariant (No Cooldown Immunity / Sweet-Spot Burst)
|
||||
// =========================================================================
|
||||
describe('Dimension 3: NextDelay = 0 Invariant (No Cooldown Immunity / Sweet-Spot Burst)', () => {
|
||||
it('verifies simultaneous hits: 5 overlapping shards hitting the same target on the same tick all deal full damage', () => {
|
||||
const shards: Projectile[] = [1, 2, 3, 4, 5].map(i => ({
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 5,
|
||||
fromPlayer: true,
|
||||
missileType: `blizzard${((i - 1) % 4) + 1}`,
|
||||
}))
|
||||
|
||||
const target: ProjectileTarget = { index: 99, x: 200, y: 200, radius: 20, alive: true }
|
||||
const res = tickProjectiles(shards, [target], openTerrain)
|
||||
|
||||
// All 5 shards must hit the target without being blocked by immunity frames
|
||||
expect(res.hits.length).toBe(5)
|
||||
let totalDamage = 0
|
||||
for (const hit of res.hits) {
|
||||
expect(hit.targetIndex).toBe(99)
|
||||
expect(hit.damage).toBe(100)
|
||||
expect(hit.statusEffect).toBe('chill')
|
||||
totalDamage += hit.damage
|
||||
}
|
||||
expect(totalDamage).toBe(500)
|
||||
})
|
||||
|
||||
it('verifies consecutive tick hits: shards hitting on ticks t, t+1, t+2, t+3 all deal damage without NextDelay lockout', () => {
|
||||
const target: ProjectileTarget = { index: 42, x: 200, y: 200, radius: 20, alive: true }
|
||||
let totalDamage = 0
|
||||
const recordedHits: number[] = []
|
||||
|
||||
// On 4 consecutive ticks, a new shard hits the target
|
||||
for (let t = 1; t <= 4; t++) {
|
||||
const shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 150,
|
||||
ttl: 5,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
|
||||
const res = tickProjectiles([shard], [target], openTerrain)
|
||||
expect(res.hits.length).toBe(1)
|
||||
expect(res.hits[0]!.damage).toBe(150)
|
||||
recordedHits.push(res.hits[0]!.damage)
|
||||
totalDamage += res.hits[0]!.damage
|
||||
}
|
||||
|
||||
expect(recordedHits.length).toBe(4)
|
||||
expect(totalDamage).toBe(600) // 150 * 4
|
||||
})
|
||||
|
||||
it('differential contrast oracle: compares NextDelay = 0 (Blizzard) against NextDelay = 4 (Nova / Chain Lightning)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const engine = new MissileEngine(registry)
|
||||
|
||||
const ownerStatList = new UnitStatList(registry, {})
|
||||
const owner: CombatUnitContext = {
|
||||
id: 'sorc',
|
||||
name: 'Sorceress',
|
||||
isUndead: false,
|
||||
isDemon: false,
|
||||
statList: ownerStatList,
|
||||
stateBus: new StateBus(ownerStatList, registry),
|
||||
}
|
||||
|
||||
const dummyStatList = new UnitStatList(registry, { maxhp: 50000 * 256, hitpoints: 50000 * 256 })
|
||||
const dummy: CombatUnitContext = {
|
||||
id: 'boss-target',
|
||||
name: 'BossDummy',
|
||||
isUndead: false,
|
||||
isDemon: true,
|
||||
statList: dummyStatList,
|
||||
stateBus: new StateBus(dummyStatList, registry),
|
||||
}
|
||||
|
||||
const targetPositions = new Map([['boss-target', { x: 100, y: 100 }]])
|
||||
|
||||
// 1. Contrast Test: Nova with nextDelay = 4
|
||||
// Spawn 2 Nova missiles hitting on tick 1 and tick 2
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'nova',
|
||||
sourceSkillId: 48,
|
||||
slvl: 20,
|
||||
owner,
|
||||
startX: 100,
|
||||
startY: 100,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
dmgPacket: { skillId: 48, attackKind: 'spell', elemType: 'ltng', elemMin256: 100 * 256, elemMax256: 100 * 256, autoHit: true },
|
||||
})
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'nova',
|
||||
sourceSkillId: 48,
|
||||
slvl: 20,
|
||||
owner,
|
||||
startX: 100,
|
||||
startY: 100,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
dmgPacket: { skillId: 48, attackKind: 'spell', elemType: 'ltng', elemMin256: 100 * 256, elemMax256: 100 * 256, autoHit: true },
|
||||
})
|
||||
|
||||
const novaRes = engine.tick(1, [dummy], targetPositions)
|
||||
// Under NextDelay = 4, first Nova hits, second Nova is locked out on the same tick!
|
||||
expect(novaRes.hits.length).toBe(1)
|
||||
expect(engine.isTargetInNextDelay('boss-target', 1)).toBe(true)
|
||||
|
||||
// 2. Blizzard Test: NextDelay = 0
|
||||
// In contrast, Blizzard has NextDelay = 0 (empty in Missiles.txt)
|
||||
const b1 = registry.getMissileByName('blizzard1')!
|
||||
expect(b1.nextHit).toBe(false)
|
||||
expect(b1.nextDelay).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Wall Collisions & Resource Cleanup
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Wall Collisions & Resource Cleanup', () => {
|
||||
it('verifies descending shard hitting a wall detonates immediately and spawns impact explosion', () => {
|
||||
const shard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 150,
|
||||
y: 150,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 70,
|
||||
damage: 100,
|
||||
ttl: 6,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard4',
|
||||
}
|
||||
|
||||
const res = tickProjectiles([shard], [], wallTerrain)
|
||||
|
||||
// Shard halted and destroyed by wall
|
||||
expect(res.wallHits).toBe(1)
|
||||
expect(res.alive.some(p => p.missileType === 'blizzard4')).toBe(false)
|
||||
|
||||
// Wall hit event recorded
|
||||
expect(res.wallHitEvents).toBeDefined()
|
||||
expect(res.wallHitEvents!.length).toBe(1)
|
||||
expect(res.wallHitEvents![0]!.missileType).toBe('blizzard4')
|
||||
|
||||
// Explosion spawned
|
||||
expect(res.spawnedProjectiles).toBeDefined()
|
||||
expect(res.spawnedProjectiles!.length).toBe(1)
|
||||
const exp = res.spawnedProjectiles![0]!
|
||||
expect(exp.missileType).toBe('blizzardexplode1')
|
||||
expect(exp.ttl).toBe(6)
|
||||
})
|
||||
|
||||
it('verifies 1,000 shards fired into solid walls terminate cleanly without memory leak or hanging', () => {
|
||||
const shards: Projectile[] = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
shards.push({
|
||||
skillId: '59',
|
||||
x: 100 + (i % 50),
|
||||
y: 100 + Math.floor(i / 50),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 120,
|
||||
damage: 50,
|
||||
ttl: 9,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
})
|
||||
}
|
||||
|
||||
// Tick 1: all 1,000 shards strike the wall immediately
|
||||
const tick1 = tickProjectiles(shards, [], wallTerrain)
|
||||
expect(tick1.wallHits).toBe(1000)
|
||||
// None of the original shards should survive
|
||||
expect(tick1.alive.every(p => p.missileType === 'blizzardexplode1')).toBe(true)
|
||||
expect(tick1.alive.length).toBe(1000) // 1,000 spawned explosions
|
||||
|
||||
// Now tick the 1,000 explosions over open terrain until they all expire (range = 6)
|
||||
let active = tick1.alive as Projectile[]
|
||||
for (let t = 1; t <= 6; t++) {
|
||||
const step = tickProjectiles(active, [], openTerrain)
|
||||
active = step.alive as Projectile[]
|
||||
}
|
||||
|
||||
// After 6 ticks, the explosions reached ttl = 0
|
||||
// Next tick (tick 7): all 1,000 explosions expire
|
||||
const finalStep = tickProjectiles(active, [], openTerrain)
|
||||
expect(finalStep.expired).toBe(1000)
|
||||
expect(finalStep.alive.length).toBe(0) // Clean zero active missiles!
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: 100,000-Tick Long-Run Stress Simulation (Crashes, NaNs, Leaks)
|
||||
// =========================================================================
|
||||
describe('Dimension 5: 100,000-Tick Long-Run Stress Simulation (Crashes, NaNs, Leaks)', () => {
|
||||
it('runs 100,000 ticks of continuous Blizzard casting without crashes, NaNs, or memory leaks', () => {
|
||||
const simStartMs = Date.now()
|
||||
const heapBefore = process.memoryUsage().heapUsed
|
||||
|
||||
let activeProjectiles: Projectile[] = []
|
||||
let totalCasts = 0
|
||||
let totalHits = 0
|
||||
let totalWallHits = 0
|
||||
let totalExpired = 0
|
||||
let peakActiveCount = 0
|
||||
|
||||
// Dynamic targets moving in combat arena
|
||||
const targets: ProjectileTarget[] = [
|
||||
{ index: 0, x: 200, y: 200, radius: 25, alive: true },
|
||||
{ index: 1, x: 215, y: 210, radius: 20, alive: true },
|
||||
{ index: 2, x: 190, y: 220, radius: 20, alive: true },
|
||||
{ index: 3, x: 205, y: 185, radius: 20, alive: true },
|
||||
{ index: 4, x: 300, y: 300, radius: 25, alive: true },
|
||||
]
|
||||
|
||||
// Semi-solid map terrain with boundary walls
|
||||
const hybridTerrain = {
|
||||
overlap: (x: number, y: number) => (x > 380 || x < 20 || y > 380 || y < 20 ? 1 : 0),
|
||||
}
|
||||
|
||||
// 100,000 ticks = 4,000 seconds = 66.6 minutes of non-stop game simulation
|
||||
for (let tick = 0; tick < 100000; tick++) {
|
||||
// Cast Blizzard on cooldown (every 45 ticks = 1.80s cast delay)
|
||||
if (tick % 45 === 0) {
|
||||
totalCasts++
|
||||
// Settle Blizzard center with slight spatial variation across casts
|
||||
const cx = 200 + ((tick / 45) % 5) * 10
|
||||
const cy = 200 + ((tick / 45) % 7) * 8
|
||||
activeProjectiles.push({
|
||||
skillId: '59',
|
||||
x: cx,
|
||||
y: cy,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 150,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Monsters oscillate positions slightly every 10 ticks
|
||||
if (tick % 10 === 0) {
|
||||
for (let m = 0; m < targets.length; m++) {
|
||||
const angle = (tick * 0.05) + m
|
||||
targets[m] = {
|
||||
...targets[m]!,
|
||||
x: 200 + Math.cos(angle) * 30,
|
||||
y: 200 + Math.sin(angle) * 15 * ISO_GROUND_ASPECT_RATIO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance simulation tick
|
||||
const outcome = tickProjectiles(activeProjectiles, targets, hybridTerrain)
|
||||
activeProjectiles = outcome.alive as Projectile[]
|
||||
totalHits += outcome.hits.length
|
||||
totalWallHits += outcome.wallHits
|
||||
totalExpired += outcome.expired
|
||||
|
||||
if (activeProjectiles.length > peakActiveCount) {
|
||||
peakActiveCount = activeProjectiles.length
|
||||
}
|
||||
|
||||
// INVARIANT 1: Bounded missile count — no runaway memory leak!
|
||||
// At any tick, at most 3 blizzard centers (ttl 100 / 45 = 2.22) can exist concurrently,
|
||||
// each emitting shards with ttl 9. Peak active projectiles must never exceed 60.
|
||||
if (activeProjectiles.length > 60) {
|
||||
throw new Error(`Memory leak detected at tick ${tick}: active projectile count reached ${activeProjectiles.length}`)
|
||||
}
|
||||
|
||||
// INVARIANT 2: Zero NaNs or non-finite numbers in any active missile
|
||||
for (const p of activeProjectiles) {
|
||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
|
||||
throw new Error(`NaN detected in position at tick ${tick}: x=${p.x}, y=${p.y}`)
|
||||
}
|
||||
if (p.altitude !== undefined && (!Number.isFinite(p.altitude) || p.altitude < 0)) {
|
||||
throw new Error(`Invalid altitude at tick ${tick}: altitude=${p.altitude}`)
|
||||
}
|
||||
if (!Number.isFinite(p.ttl) || p.ttl < 0) {
|
||||
throw new Error(`Invalid ttl at tick ${tick}: ttl=${p.ttl}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simElapsedMs = Date.now() - simStartMs
|
||||
const heapAfter = process.memoryUsage().heapUsed
|
||||
const heapDiffMb = (heapAfter - heapBefore) / (1024 * 1024)
|
||||
|
||||
// Assertions on 100,000 tick completion
|
||||
expect(totalCasts).toBe(Math.floor(100000 / 45) + 1) // 2,223 Blizzard casts
|
||||
expect(totalHits).toBeGreaterThan(1000) // thousands of hits registered
|
||||
expect(peakActiveCount).toBeLessThanOrEqual(45) // comfortably bounded
|
||||
expect(activeProjectiles.length).toBeLessThanOrEqual(25) // final active count is bounded
|
||||
expect(heapDiffMb).toBeLessThan(15) // stable heap (< 15 MB variance)
|
||||
|
||||
console.log(`[100,000-Tick Blizzard Stress Sim Completed]`)
|
||||
console.log(` Duration: ${simElapsedMs} ms (${(100000 / (simElapsedMs / 1000)).toFixed(0)} ticks/sec)`)
|
||||
console.log(` Total Casts: ${totalCasts}`)
|
||||
console.log(` Total Hits: ${totalHits}`)
|
||||
console.log(` Total Wall Hits: ${totalWallHits}`)
|
||||
console.log(` Total Expired Missiles: ${totalExpired}`)
|
||||
console.log(` Peak Active Projectiles: ${peakActiveCount}`)
|
||||
console.log(` Final Active Projectiles: ${activeProjectiles.length}`)
|
||||
console.log(` Heap Diff: ${heapDiffMb.toFixed(2)} MB`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,21 +1,14 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #059: Blizzard (SOR)
|
||||
* Gitea Tracking Issue: #213 / #400
|
||||
* Gitea Tracking Issue: #213
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-059-blizzard.ts'
|
||||
import {
|
||||
calculateSkillDamage,
|
||||
calculateBlizzardDamage,
|
||||
tickProjectiles,
|
||||
CANONICAL_113C_MISSILES,
|
||||
type Projectile,
|
||||
} from '../../../src/game/skills.ts'
|
||||
|
||||
describe('[Skill #059] Blizzard (SOR) — 1.13c Parity (Issue #213 / #400)', () => {
|
||||
describe('[Skill #059] Blizzard (SOR) — 1.13c Parity (Issue #213)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(59)!
|
||||
|
|
@ -113,103 +106,4 @@ describe('[Skill #059] Blizzard (SOR) — 1.13c Parity (Issue #213 / #400)', ()
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('calculateSkillDamage routes Skill 59 and evaluates exact 1.13c damage ranges at slvl 1, 10, 20', () => {
|
||||
// slvl 1: { min: 45, max: 75 }
|
||||
const d1 = calculateSkillDamage(59, 1)
|
||||
expect(d1).toEqual({ min: 45, max: 75 })
|
||||
|
||||
// slvl 10: { min: 210, max: 249 }
|
||||
const d10 = calculateSkillDamage(59, 10)
|
||||
expect(d10).toEqual({ min: 210, max: 249 })
|
||||
|
||||
// slvl 20: { min: 570, max: 619 }
|
||||
const d20 = calculateSkillDamage(59, 20)
|
||||
expect(d20).toEqual({ min: 570, max: 619 })
|
||||
|
||||
// String skill ID routing
|
||||
expect(calculateSkillDamage('59', 1)).toEqual({ min: 45, max: 75 })
|
||||
expect(calculateSkillDamage('blizzard', 20)).toEqual({ min: 570, max: 619 })
|
||||
|
||||
// Synergy integration via multiplier and synergy object
|
||||
expect(calculateSkillDamage(59, 20, 2.0)).toEqual({ min: 1140, max: 1238 })
|
||||
expect(calculateSkillDamage(59, 20, { iceBolt: 20 })).toEqual({ min: 1140, max: 1238 })
|
||||
})
|
||||
|
||||
it('blizzardcenter missile and falling shards scale with slvl and do not default to 45', () => {
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
// Case A: slvl = 1 -> damage range { 45, 75 } -> average 60
|
||||
const dmgRange1 = calculateSkillDamage(59, 1)
|
||||
const avgDamage1 = Math.round((dmgRange1.min + dmgRange1.max) / 2)
|
||||
expect(avgDamage1).toBe(60)
|
||||
|
||||
const centerL1: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: avgDamage1,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
ageTicks: 3, // next tick will be tick 4 -> emit shard
|
||||
}
|
||||
|
||||
const outcomeL1 = tickProjectiles([centerL1], [], openTerrain)
|
||||
expect(outcomeL1.spawnedProjectiles).toBeDefined()
|
||||
expect(outcomeL1.spawnedProjectiles!.length).toBe(1)
|
||||
const shardL1 = outcomeL1.spawnedProjectiles![0]!
|
||||
expect(shardL1.damage).toBe(60)
|
||||
expect(shardL1.damage).not.toBe(45)
|
||||
|
||||
// Case B: slvl = 20 -> damage range { 570, 619 } -> average 595
|
||||
const dmgRange20 = calculateSkillDamage(59, 20)
|
||||
const avgDamage20 = Math.round((dmgRange20.min + dmgRange20.max) / 2)
|
||||
expect(avgDamage20).toBe(595)
|
||||
|
||||
const centerL20: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: avgDamage20,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzardcenter',
|
||||
subMissileIdx: 0,
|
||||
ageTicks: 3, // next tick will be tick 4 -> emit shard
|
||||
}
|
||||
|
||||
const outcomeL20 = tickProjectiles([centerL20], [], openTerrain)
|
||||
expect(outcomeL20.spawnedProjectiles).toBeDefined()
|
||||
expect(outcomeL20.spawnedProjectiles!.length).toBe(1)
|
||||
const shardL20 = outcomeL20.spawnedProjectiles![0]!
|
||||
expect(shardL20.damage).toBe(595)
|
||||
expect(shardL20.damage).not.toBe(45)
|
||||
|
||||
// Shard expiry ground explosion inherits shard damage
|
||||
const expiringShard: Projectile = {
|
||||
skillId: '59',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
altitude: 0,
|
||||
damage: 595,
|
||||
ttl: 0,
|
||||
fromPlayer: true,
|
||||
missileType: 'blizzard1',
|
||||
}
|
||||
const expOutcome = tickProjectiles([expiringShard], [], openTerrain)
|
||||
expect(expOutcome.spawnedProjectiles?.length).toBe(1)
|
||||
expect(expOutcome.spawnedProjectiles![0]!.damage).toBe(595)
|
||||
})
|
||||
|
||||
it('CANONICAL_113C_MISSILES blizzardexplode3 matches Missiles.txt celFile icestormimpactvar02', () => {
|
||||
expect(CANONICAL_113C_MISSILES.blizzardexplode3.celFile).toBe('icestormimpactvar02')
|
||||
})
|
||||
})
|
||||
|
|
|
|||