diablo2-web/tests/skills/nec/necro-assets.test.ts

386 lines
17 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import {
MISSILE_METAS,
CORPSEEXPLOSION_META,
POISONCORPSEEXPLOSION_META,
POISONEXPLOSIONCLOUD_META,
POISONPUFF_META,
CURSECAST_META,
BONECAST_META,
BONESPEARTRAIL_META,
REVIVE_META,
REVIVESMALL_META,
REVIVEMEDIUM_META,
REVIVELARGE_META,
BONESPEAREXPLODE_META,
type MissileMeta,
} from '../../../src/render/missiles-meta.ts'
import {
OVERLAY_METAS,
CURSE_AMPLIFY_DAMAGE_OVERLAY_META,
CURSE_DIM_VISION_OVERLAY_META,
CURSE_WEAKEN_OVERLAY_META,
CURSE_IRON_MAIDEN_OVERLAY_META,
CURSE_TERROR_OVERLAY_META,
CURSE_CONFUSE_OVERLAY_META,
CURSE_LIFE_TAP_OVERLAY_META,
CURSE_ATTRACT_OVERLAY_META,
CURSE_DECREPIFY_OVERLAY_META,
CURSE_LOWER_RESIST_OVERLAY_META,
HIT_IRON_MAIDEN_OVERLAY_META,
HIT_LIFE_TAP_OVERLAY_META,
BONE_ARMOR_CAST_OVERLAY_META,
BONE_ARMOR_FRONT_OVERLAY_META,
BONE_ARMOR_BACK_OVERLAY_META,
type OverlayMeta,
} from '../../../src/render/overlays-meta.ts'
// Helper function to decode 32-bit RGBA PNG
function decodeRgbaPng(buf: Buffer) {
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
const bitDepth = buf[24]!
const colorType = buf[25]!
const idatParts: Buffer[] = []
let offset = 8
while (offset < buf.length) {
const len = buf.readUInt32BE(offset)
const type = buf.subarray(offset + 4, offset + 8).toString('ascii')
if (type === 'IDAT') {
idatParts.push(buf.subarray(offset + 8, offset + 8 + len))
}
offset += 12 + len
}
const idat = Buffer.concat(idatParts)
const raw = zlib.inflateSync(idat)
const bpp = 4
const rowBytes = width * bpp
const pixels = new Uint8Array(width * height * 4)
let srcAt = 0
let dstAt = 0
for (let y = 0; y < height; y++) {
const filter = raw[srcAt++]
const row = raw.subarray(srcAt, srcAt + rowBytes)
srcAt += rowBytes
const prevRow = y > 0 ? pixels.subarray((y - 1) * rowBytes, y * rowBytes) : null
const curRow = pixels.subarray(dstAt, dstAt + rowBytes)
for (let x = 0; x < rowBytes; x++) {
const left = x >= bpp ? curRow[x - bpp]! : 0
const up = prevRow ? prevRow[x]! : 0
const upLeft = prevRow && x >= bpp ? prevRow[x - bpp]! : 0
let val = row[x]!
if (filter === 0) {
// None
} else if (filter === 1) {
val = (val + left) & 0xff
} else if (filter === 2) {
val = (val + up) & 0xff
} else if (filter === 3) {
val = (val + Math.floor((left + up) / 2)) & 0xff
} else if (filter === 4) {
const p = left + up - upLeft
const pa = Math.abs(p - left)
const pb = Math.abs(p - up)
const pc = Math.abs(p - upLeft)
let pr = upLeft
if (pa <= pb && pa <= pc) pr = left
else if (pb <= pc) pr = up
val = (val + pr) & 0xff
}
curRow[x] = val
}
dstAt += rowBytes
}
return { width, height, bitDepth, colorType, pixels }
}
describe('Milestone M1 — Necromancer Asset Packaging & Registration', () => {
const publicMissilesDir = path.resolve(__dirname, '../../../public/missiles')
const packMissilesDir = path.resolve(__dirname, '../../../samples/d2-packs/missiles')
const publicOverlaysDir = path.resolve(__dirname, '../../../public/overlays')
const packOverlaysDir = path.resolve(__dirname, '../../../samples/d2-packs/overlays')
const NECRO_MISSILES = [
{ name: 'corpseexplosion', celFile: 'CorpseExplodeGuts', meta: CORPSEEXPLOSION_META },
{ name: 'poisoncorpseexplosion', celFile: 'CorpseExplodeGutsPoison', meta: POISONCORPSEEXPLOSION_META },
{ name: 'poisonexplosioncloud', celFile: 'PoisonSparks', meta: POISONEXPLOSIONCLOUD_META },
{ name: 'poisonpuff', celFile: 'PoisonSmokePuff', meta: POISONPUFF_META },
{ name: 'cursecast', celFile: 'CurseCast', meta: CURSECAST_META },
{ name: 'bonecast', celFile: 'BoneCast', meta: BONECAST_META },
{ name: 'bonespeartrail', celFile: 'BoneSpearTrail', meta: BONESPEARTRAIL_META },
{ name: 'revive', celFile: 'Revive', meta: REVIVE_META },
] as const
const NECRO_MISSILE_ALIASES = [
{ name: 'revivesmall', source: 'revive', meta: REVIVESMALL_META },
{ name: 'revivemedium', source: 'revive', meta: REVIVEMEDIUM_META },
{ name: 'revivelarge', source: 'revive', meta: REVIVELARGE_META },
{ name: 'bonespearexplode', source: 'teethexplode', meta: BONESPEAREXPLODE_META },
] as const
const NECRO_OVERLAYS = [
// 10 Curses overhead state icons
{ name: 'curseamplifydamage', celFile: 'CurseAmplifyDamageEffect', meta: CURSE_AMPLIFY_DAMAGE_OVERLAY_META, preDraw: false },
{ name: 'cursedimvision', celFile: 'CurseDimVisionEffect', meta: CURSE_DIM_VISION_OVERLAY_META, preDraw: false },
{ name: 'curseweaken', celFile: 'CurseWeakenEffect', meta: CURSE_WEAKEN_OVERLAY_META, preDraw: false },
{ name: 'curseironmaiden', celFile: 'CurseIronMaidenEffect', meta: CURSE_IRON_MAIDEN_OVERLAY_META, preDraw: false },
{ name: 'curseterror', celFile: 'CurseTerrorEffect', meta: CURSE_TERROR_OVERLAY_META, preDraw: false },
{ name: 'curseconfuse', celFile: 'CurseConfuseEffect', meta: CURSE_CONFUSE_OVERLAY_META, preDraw: false },
{ name: 'cursereversevampire', celFile: 'CurseReverseVampireEffect', meta: CURSE_LIFE_TAP_OVERLAY_META, preDraw: false },
{ name: 'curseattract', celFile: 'CurseAttractHarmEffect', meta: CURSE_ATTRACT_OVERLAY_META, preDraw: false },
{ name: 'cursedecrepify', celFile: 'CurseDecrepifyEffect', meta: CURSE_DECREPIFY_OVERLAY_META, preDraw: false },
{ name: 'curselowerresist', celFile: 'CurseLowerResistEffect', meta: CURSE_LOWER_RESIST_OVERLAY_META, preDraw: false },
// 2 Hit overlays
{ name: 'hit_ironmaiden', celFile: 'AuraThornsHit', meta: HIT_IRON_MAIDEN_OVERLAY_META, preDraw: false },
{ name: 'hit_reversevampire', celFile: 'CurseRevVampireHit', meta: HIT_LIFE_TAP_OVERLAY_META, preDraw: false },
// 3 Bone Armor overlays
{ name: 'bonearmor_cast', celFile: 'BoneShieldEmerge', meta: BONE_ARMOR_CAST_OVERLAY_META, preDraw: false },
{ name: 'bonearmor_front', celFile: 'BoneShieldFront', meta: BONE_ARMOR_FRONT_OVERLAY_META, preDraw: false },
{ name: 'bonearmor_back', celFile: 'BoneShieldBack', meta: BONE_ARMOR_BACK_OVERLAY_META, preDraw: true },
] as const
describe('Suite 1: Physical Asset Existence & File Mirroring', () => {
it('1.1 Verifies all Necromancer missile PNG and JSON files exist in public/missiles and samples/d2-packs/missiles', () => {
const allMissiles = [...NECRO_MISSILES.map(m => m.name), ...NECRO_MISSILE_ALIASES.map(a => a.name)]
for (const name of allMissiles) {
for (const dir of [publicMissilesDir, packMissilesDir]) {
const pngPath = path.join(dir, `${name}.png`)
const jsonPath = path.join(dir, `${name}.json`)
expect(fs.existsSync(pngPath), `Missing missile PNG: ${pngPath}`).toBe(true)
expect(fs.existsSync(jsonPath), `Missing missile JSON: ${jsonPath}`).toBe(true)
const stat = fs.statSync(pngPath)
expect(stat.size, `${pngPath} file size must be > 1000 bytes`).toBeGreaterThan(1000)
const buf = fs.readFileSync(pngPath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50) // 'P'
expect(buf[2]).toBe(0x4e) // 'N'
expect(buf[3]).toBe(0x47) // 'G'
}
}
})
it('1.2 Verifies all 15 Necromancer overlay PNG and JSON files exist in public/overlays and samples/d2-packs/overlays', () => {
for (const o of NECRO_OVERLAYS) {
for (const dir of [publicOverlaysDir, packOverlaysDir]) {
const pngPath = path.join(dir, `${o.name}.png`)
const jsonPath = path.join(dir, `${o.name}.json`)
expect(fs.existsSync(pngPath), `Missing overlay PNG: ${pngPath}`).toBe(true)
expect(fs.existsSync(jsonPath), `Missing overlay JSON: ${jsonPath}`).toBe(true)
const stat = fs.statSync(pngPath)
expect(stat.size, `${pngPath} file size must be > 1000 bytes`).toBeGreaterThan(1000)
const buf = fs.readFileSync(pngPath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
}
}
})
})
describe('Suite 2: Metadata Registry & Frame Geometry Invariants', () => {
it('2.1 Verifies Necromancer missiles metadata schema and geometry bounds', () => {
for (const m of NECRO_MISSILES) {
const meta = MISSILE_METAS[m.name]
expect(meta, `MISSILE_METAS missing ${m.name}`).toBeDefined()
expect(meta!.name).toBe(m.name)
expect(meta!.celFile).toBe(m.celFile)
expect(meta!.width).toBeGreaterThan(0)
expect(meta!.height).toBeGreaterThan(0)
expect(meta!.directions).toBeGreaterThan(0)
expect(meta!.framesPerDirection).toBeGreaterThan(0)
expect(meta!.animSpeed).toBe(16)
expect(meta!.groups.length).toBe(meta!.directions)
for (const group of meta!.groups) {
expect(group.length).toBe(meta!.framesPerDirection)
for (const [x, y, w, h, ax, ay] of group) {
expect(x).toBeGreaterThanOrEqual(0)
expect(y).toBeGreaterThanOrEqual(0)
expect(x + w).toBeLessThanOrEqual(meta!.width)
expect(y + h).toBeLessThanOrEqual(meta!.height)
expect(w).toBeGreaterThan(0)
expect(h).toBeGreaterThan(0)
expect(typeof ax).toBe('number')
expect(typeof ay).toBe('number')
}
}
}
})
it('2.2 Verifies Necromancer overlays metadata schema, frames, and preDraw flags', () => {
for (const o of NECRO_OVERLAYS) {
const meta = OVERLAY_METAS[o.name]
expect(meta, `OVERLAY_METAS missing ${o.name}`).toBeDefined()
expect(meta!.name).toBe(o.name)
expect(meta!.celFile).toBe(o.celFile)
expect(meta!.width).toBeGreaterThan(0)
expect(meta!.height).toBeGreaterThan(0)
expect(meta!.directions).toBe(1)
expect(meta!.frames).toBeGreaterThan(0)
expect(meta!.framesPerDirection).toBe(meta!.frames)
expect(meta!.animRate).toBe(16)
expect(meta!.trans).toBe(3)
expect(meta!.preDraw).toBe(o.preDraw)
expect(meta!.groups.length).toBe(1)
expect(meta!.groups[0]?.length).toBe(meta!.frames)
for (const [x, y, w, h, ax, ay] of meta!.groups[0]!) {
expect(x).toBeGreaterThanOrEqual(0)
expect(y).toBeGreaterThanOrEqual(0)
expect(x + w).toBeLessThanOrEqual(meta!.width)
expect(y + h).toBeLessThanOrEqual(meta!.height)
expect(w).toBeGreaterThan(0)
expect(h).toBeGreaterThan(0)
expect(typeof ax).toBe('number')
expect(typeof ay).toBe('number')
}
}
})
it('2.3 Verifies CelFile and alias lookups in MISSILE_METAS and OVERLAY_METAS', () => {
// Missile CelFiles
expect(MISSILE_METAS['CorpseExplodeGuts']).toBe(CORPSEEXPLOSION_META)
expect(MISSILE_METAS['CorpseExplodeGutsPoison']).toBe(POISONCORPSEEXPLOSION_META)
expect(MISSILE_METAS['PoisonSparks']).toBe(POISONEXPLOSIONCLOUD_META)
expect(MISSILE_METAS['PoisonSmokePuff']).toBe(POISONPUFF_META)
expect(MISSILE_METAS['CurseCast']).toBe(CURSECAST_META)
expect(MISSILE_METAS['BoneCast']).toBe(BONECAST_META)
expect(MISSILE_METAS['BoneSpearTrail']).toBe(BONESPEARTRAIL_META)
expect(MISSILE_METAS['Revive']).toBe(REVIVE_META)
// Overlay CelFiles & aliases
expect(OVERLAY_METAS['CurseAmplifyDamageEffect']).toBe(CURSE_AMPLIFY_DAMAGE_OVERLAY_META)
expect(OVERLAY_METAS['amplify_damage']).toBe(CURSE_AMPLIFY_DAMAGE_OVERLAY_META)
expect(OVERLAY_METAS['cursedimvision']).toBe(CURSE_DIM_VISION_OVERLAY_META)
expect(OVERLAY_METAS['dim_vision']).toBe(CURSE_DIM_VISION_OVERLAY_META)
expect(OVERLAY_METAS['curseweaken']).toBe(CURSE_WEAKEN_OVERLAY_META)
expect(OVERLAY_METAS['weaken']).toBe(CURSE_WEAKEN_OVERLAY_META)
expect(OVERLAY_METAS['curseironmaiden']).toBe(CURSE_IRON_MAIDEN_OVERLAY_META)
expect(OVERLAY_METAS['iron_maiden']).toBe(CURSE_IRON_MAIDEN_OVERLAY_META)
expect(OVERLAY_METAS['curseterror']).toBe(CURSE_TERROR_OVERLAY_META)
expect(OVERLAY_METAS['terror']).toBe(CURSE_TERROR_OVERLAY_META)
expect(OVERLAY_METAS['curseconfuse']).toBe(CURSE_CONFUSE_OVERLAY_META)
expect(OVERLAY_METAS['confuse']).toBe(CURSE_CONFUSE_OVERLAY_META)
expect(OVERLAY_METAS['cursereversevampire']).toBe(CURSE_LIFE_TAP_OVERLAY_META)
expect(OVERLAY_METAS['lifetap']).toBe(CURSE_LIFE_TAP_OVERLAY_META)
expect(OVERLAY_METAS['curseattract']).toBe(CURSE_ATTRACT_OVERLAY_META)
expect(OVERLAY_METAS['attract']).toBe(CURSE_ATTRACT_OVERLAY_META)
expect(OVERLAY_METAS['cursedecrepify']).toBe(CURSE_DECREPIFY_OVERLAY_META)
expect(OVERLAY_METAS['decrepify']).toBe(CURSE_DECREPIFY_OVERLAY_META)
expect(OVERLAY_METAS['curselowerresist']).toBe(CURSE_LOWER_RESIST_OVERLAY_META)
expect(OVERLAY_METAS['lower_resist']).toBe(CURSE_LOWER_RESIST_OVERLAY_META)
expect(OVERLAY_METAS['hit_ironmaiden']).toBe(HIT_IRON_MAIDEN_OVERLAY_META)
expect(OVERLAY_METAS['hit_reversevampire']).toBe(HIT_LIFE_TAP_OVERLAY_META)
expect(OVERLAY_METAS['bonearmor_cast']).toBe(BONE_ARMOR_CAST_OVERLAY_META)
expect(OVERLAY_METAS['bonearmor_front']).toBe(BONE_ARMOR_FRONT_OVERLAY_META)
expect(OVERLAY_METAS['bonearmor_back']).toBe(BONE_ARMOR_BACK_OVERLAY_META)
})
})
describe('Suite 3: 32-bit RGBA & Transparent Border Thresholding (Zero Artifacts)', () => {
it('3.1 Verifies all baked Necromancer missiles have 32-bit RGBA and zero transparent border artifacts', () => {
const targetMissiles = ['corpseexplosion', 'poisoncorpseexplosion', 'poisonexplosioncloud', 'poisonpuff', 'cursecast', 'bonecast', 'bonespeartrail', 'revive']
for (const name of targetMissiles) {
const pngPath = path.join(publicMissilesDir, `${name}.png`)
const buf = fs.readFileSync(pngPath)
const decoded = decodeRgbaPng(buf)
expect(decoded.colorType, `${name}.png must be 32-bit RGBA (colorType 6)`).toBe(6)
expect(decoded.bitDepth, `${name}.png bitDepth must be 8`).toBe(8)
let blackOpaqueCount = 0
let visibleCount = 0
let gradientCount = 0
for (let i = 0; i < decoded.pixels.length; i += 4) {
const r = decoded.pixels[i]!
const g = decoded.pixels[i + 1]!
const b = decoded.pixels[i + 2]!
const a = decoded.pixels[i + 3]!
const m = Math.max(r, g, b)
// Threshold invariant: max(r,g,b) <= 4 must have alpha === 0
if (m <= 4 && a > 0) {
blackOpaqueCount++
}
if (m >= 30 && a > 0) {
visibleCount++
}
if (m > 4 && m < 60 && a > 0 && a < 255) {
gradientCount++
}
}
expect(blackOpaqueCount, `${name}.png contains ${blackOpaqueCount} dark boundary artifact pixels`).toBe(0)
expect(visibleCount, `${name}.png must contain visible spell pixels`).toBeGreaterThan(0)
expect(gradientCount, `${name}.png must contain anti-aliased edge gradient pixels`).toBeGreaterThan(0)
}
})
it('3.2 Verifies all 15 baked Necromancer overlays have 32-bit RGBA and zero transparent border artifacts', () => {
for (const o of NECRO_OVERLAYS) {
const pngPath = path.join(publicOverlaysDir, `${o.name}.png`)
const buf = fs.readFileSync(pngPath)
const decoded = decodeRgbaPng(buf)
expect(decoded.colorType, `${o.name}.png must be 32-bit RGBA (colorType 6)`).toBe(6)
expect(decoded.bitDepth, `${o.name}.png bitDepth must be 8`).toBe(8)
let blackOpaqueCount = 0
let visibleCount = 0
for (let i = 0; i < decoded.pixels.length; i += 4) {
const r = decoded.pixels[i]!
const g = decoded.pixels[i + 1]!
const b = decoded.pixels[i + 2]!
const a = decoded.pixels[i + 3]!
const m = Math.max(r, g, b)
// Threshold invariant: max(r,g,b) <= 4 must have alpha === 0
if (m <= 4 && a > 0) {
blackOpaqueCount++
}
if (m >= 30 && a > 0) {
visibleCount++
}
}
expect(blackOpaqueCount, `${o.name}.png contains ${blackOpaqueCount} dark boundary artifact pixels`).toBe(0)
expect(visibleCount, `${o.name}.png must contain visible overlay pixels`).toBeGreaterThan(0)
}
})
})
describe('Suite 4: Zero Runtime MPQ Invariant', () => {
it('4.1 Verifies metadata files contain zero runtime MPQ, DLL or fs imports', () => {
const missilesMetaPath = path.resolve(__dirname, '../../../src/render/missiles-meta.ts')
const overlaysMetaPath = path.resolve(__dirname, '../../../src/render/overlays-meta.ts')
for (const p of [missilesMetaPath, overlaysMetaPath]) {
const content = fs.readFileSync(p, 'utf-8')
expect(content).not.toContain('node:fs')
expect(content).not.toContain('src/mpq')
expect(content).not.toContain('.mpq')
expect(content).not.toContain('.dll')
}
})
})
})