325 lines
15 KiB
TypeScript
325 lines
15 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,
|
|
MOLTENBOULDER_META,
|
|
MOLTENBOULDEREMERGE_META,
|
|
FIRESTORMMAKER_META,
|
|
ARCTICBLAST1_META,
|
|
ARCTICBLAST2_META,
|
|
TWISTER_META,
|
|
TORNADO_META,
|
|
VOLCANOBASE_META,
|
|
VOLCANOFIRE_META,
|
|
VOLCANOROCK_META,
|
|
SHOCKWAVE_META,
|
|
ARMAGEDDONROCK_META,
|
|
ARMAGEDDONTAIL_META,
|
|
HURRICANESWOOSH_META,
|
|
HURRICANEROCK_META,
|
|
HURRICANETREE_META,
|
|
type MissileMeta,
|
|
} from '../../../src/render/missiles-meta.ts'
|
|
import {
|
|
OVERLAY_METAS,
|
|
DRUID_FIRE_CAST_1_OVERLAY_META,
|
|
DRUID_FIRE_CAST_2_OVERLAY_META,
|
|
DRUIDMORPH_OVERLAY_META,
|
|
CYCLONEARMOR1FRONT_OVERLAY_META,
|
|
CYCLONEARMOR1BACK_OVERLAY_META,
|
|
CYCLONEARMOR2FRONT_OVERLAY_META,
|
|
CYCLONEARMOR2BACK_OVERLAY_META,
|
|
CYCLONEARMOR3FRONT_OVERLAY_META,
|
|
CYCLONEARMOR3BACK_OVERLAY_META,
|
|
RABIESPLAGUE_OVERLAY_META,
|
|
MAUL1FRONT_OVERLAY_META,
|
|
MAUL2FRONT_OVERLAY_META,
|
|
MAUL3FRONT_OVERLAY_META,
|
|
MAUL4FRONT_OVERLAY_META,
|
|
MAUL5FRONT_OVERLAY_META,
|
|
MAUL1BACK_OVERLAY_META,
|
|
MAUL2BACK_OVERLAY_META,
|
|
MAUL3BACK_OVERLAY_META,
|
|
MAUL4BACK_OVERLAY_META,
|
|
MAUL5BACK_OVERLAY_META,
|
|
FERALRAGE1FRONT_OVERLAY_META,
|
|
FERALRAGE2FRONT_OVERLAY_META,
|
|
FERALRAGE3FRONT_OVERLAY_META,
|
|
FERALRAGE4FRONT_OVERLAY_META,
|
|
FERALRAGE5FRONT_OVERLAY_META,
|
|
FERALRAGE1BACK_OVERLAY_META,
|
|
FERALRAGE2BACK_OVERLAY_META,
|
|
FERALRAGE3BACK_OVERLAY_META,
|
|
FERALRAGE4BACK_OVERLAY_META,
|
|
FERALRAGE5BACK_OVERLAY_META,
|
|
DIREWOLFCHARGED_OVERLAY_META,
|
|
type OverlayMeta,
|
|
} from '../../../src/render/overlays-meta.ts'
|
|
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
|
|
|
|
// Strict PNG RGBA Decoder
|
|
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 M21 — Druid 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 DRUID_MISSILES = [
|
|
{ name: 'moltenboulder', celFile: 'expansion/MoltenBoulderRoll', meta: MOLTENBOULDER_META, trans: 0 },
|
|
{ name: 'moltenboulderemerge', celFile: 'expansion/MoltenBoulderEmerge', meta: MOLTENBOULDEREMERGE_META, trans: 0 },
|
|
{ name: 'firestormmaker', celFile: 'expansion/FirestormEmitter', meta: FIRESTORMMAKER_META, trans: 1 },
|
|
{ name: 'arcticblast1', celFile: 'expansion/ArcticBlastIce', meta: ARCTICBLAST1_META, trans: 1 },
|
|
{ name: 'arcticblast2', celFile: 'expansion/ArcticBlastIce_2', meta: ARCTICBLAST2_META, trans: 1 },
|
|
{ name: 'twister', celFile: 'expansion/twister', meta: TWISTER_META, trans: 1 },
|
|
{ name: 'tornado', celFile: 'expansion/tornado', meta: TORNADO_META, trans: 1 },
|
|
{ name: 'volcanobase', celFile: 'Expansion/volcano_base', meta: VOLCANOBASE_META, trans: 0 },
|
|
{ name: 'volcanofire', celFile: 'Expansion/volcano_Fire', meta: VOLCANOFIRE_META, trans: 1 },
|
|
{ name: 'volcanorock', celFile: 'expansion/volcano_rock', meta: VOLCANOROCK_META, trans: 1 },
|
|
{ name: 'shockwave', celFile: 'expansion/shockwave', meta: SHOCKWAVE_META, trans: 1 },
|
|
{ name: 'armageddonrock', celFile: 'expansion/armageddon_rock', meta: ARMAGEDDONROCK_META, trans: 0 },
|
|
{ name: 'armageddontail', celFile: 'expansion/armageddon_fire', meta: ARMAGEDDONTAIL_META, trans: 1 },
|
|
{ name: 'hurricaneswoosh', celFile: 'expansion/hurricane_swoosh', meta: HURRICANESWOOSH_META, trans: 1 },
|
|
{ name: 'hurricanerock', celFile: 'expansion/hurricane_rocks', meta: HURRICANEROCK_META, trans: 0 },
|
|
{ name: 'hurricanetree', celFile: 'expansion/hurricane_tree', meta: HURRICANETREE_META, trans: 0 },
|
|
] as const
|
|
|
|
const DRUID_OVERLAYS = [
|
|
{ name: 'druid_fire_cast_1', celFile: 'Expansion/Fire_Overlay_A', meta: DRUID_FIRE_CAST_1_OVERLAY_META },
|
|
{ name: 'druid_fire_cast_2', celFile: 'Expansion/Fire_Overlay_B', meta: DRUID_FIRE_CAST_2_OVERLAY_META },
|
|
{ name: 'druidmorph', celFile: 'expansion/druidmorph', meta: DRUIDMORPH_OVERLAY_META },
|
|
{ name: 'cyclonearmor1front', celFile: 'Expansion/CycloneArmorAFront', meta: CYCLONEARMOR1FRONT_OVERLAY_META },
|
|
{ name: 'cyclonearmor1back', celFile: 'Expansion/CycloneArmorABack', meta: CYCLONEARMOR1BACK_OVERLAY_META },
|
|
{ name: 'cyclonearmor2front', celFile: 'Expansion/CycloneArmorBFront', meta: CYCLONEARMOR2FRONT_OVERLAY_META },
|
|
{ name: 'cyclonearmor2back', celFile: 'Expansion/CycloneArmorBBack', meta: CYCLONEARMOR2BACK_OVERLAY_META },
|
|
{ name: 'cyclonearmor3front', celFile: 'Expansion/CycloneArmorCFront', meta: CYCLONEARMOR3FRONT_OVERLAY_META },
|
|
{ name: 'cyclonearmor3back', celFile: 'Expansion/CycloneArmorCBack', meta: CYCLONEARMOR3BACK_OVERLAY_META },
|
|
{ name: 'rabiesplague', celFile: 'Expansion/rabies', meta: RABIESPLAGUE_OVERLAY_META },
|
|
{ name: 'maul1front', celFile: 'expansion/Maul_A_Front', meta: MAUL1FRONT_OVERLAY_META },
|
|
{ name: 'maul2front', celFile: 'expansion/Maul_B_Front', meta: MAUL2FRONT_OVERLAY_META },
|
|
{ name: 'maul3front', celFile: 'expansion/Maul_C_Front', meta: MAUL3FRONT_OVERLAY_META },
|
|
{ name: 'maul4front', celFile: 'expansion/Maul_D_Front', meta: MAUL4FRONT_OVERLAY_META },
|
|
{ name: 'maul5front', celFile: 'expansion/Maul_E_Front', meta: MAUL5FRONT_OVERLAY_META },
|
|
{ name: 'maul1back', celFile: 'expansion/Maul_A_Back', meta: MAUL1BACK_OVERLAY_META },
|
|
{ name: 'maul2back', celFile: 'expansion/Maul_B_Back', meta: MAUL2BACK_OVERLAY_META },
|
|
{ name: 'maul3back', celFile: 'expansion/Maul_C_Back', meta: MAUL3BACK_OVERLAY_META },
|
|
{ name: 'maul4back', celFile: 'expansion/Maul_D_Back', meta: MAUL4BACK_OVERLAY_META },
|
|
{ name: 'maul5back', celFile: 'expansion/Maul_E_Back', meta: MAUL5BACK_OVERLAY_META },
|
|
{ name: 'feralrage1front', celFile: 'expansion/feral_rage_A_Front', meta: FERALRAGE1FRONT_OVERLAY_META },
|
|
{ name: 'feralrage2front', celFile: 'expansion/feral_rage_B_Front', meta: FERALRAGE2FRONT_OVERLAY_META },
|
|
{ name: 'feralrage3front', celFile: 'expansion/feral_rage_C_Front', meta: FERALRAGE3FRONT_OVERLAY_META },
|
|
{ name: 'feralrage4front', celFile: 'expansion/feral_rage_D_Front', meta: FERALRAGE4FRONT_OVERLAY_META },
|
|
{ name: 'feralrage5front', celFile: 'expansion/feral_rage_E_Front', meta: FERALRAGE5FRONT_OVERLAY_META },
|
|
{ name: 'feralrage1back', celFile: 'expansion/feral_rage_A_Back', meta: FERALRAGE1BACK_OVERLAY_META },
|
|
{ name: 'feralrage2back', celFile: 'expansion/feral_rage_B_Back', meta: FERALRAGE2BACK_OVERLAY_META },
|
|
{ name: 'feralrage3back', celFile: 'expansion/feral_rage_C_Back', meta: FERALRAGE3BACK_OVERLAY_META },
|
|
{ name: 'feralrage4back', celFile: 'expansion/feral_rage_D_Back', meta: FERALRAGE4BACK_OVERLAY_META },
|
|
{ name: 'feralrage5back', celFile: 'expansion/feral_rage_E_Back', meta: FERALRAGE5BACK_OVERLAY_META },
|
|
{ name: 'direwolfcharged', celFile: 'expansion/DireWolfCharged', meta: DIREWOLFCHARGED_OVERLAY_META },
|
|
] as const
|
|
|
|
// =========================================================================
|
|
// Suite 1: Physical Asset Existence & Dual Mirroring
|
|
// =========================================================================
|
|
describe('Suite 1: Physical Asset Existence & Dual Mirroring', () => {
|
|
it('1.1 Verifies all 16 Druid missiles exist in both public/ and samples/d2-packs/', () => {
|
|
for (const m of DRUID_MISSILES) {
|
|
for (const dir of [publicMissilesDir, packMissilesDir]) {
|
|
const pngPath = path.join(dir, `${m.name}.png`)
|
|
const jsonPath = path.join(dir, `${m.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} must be > 1000 bytes`).toBeGreaterThan(1000)
|
|
|
|
const buf = fs.readFileSync(pngPath)
|
|
expect(buf.readUInt32BE(0)).toBe(0x89504e47) // PNG signature
|
|
}
|
|
}
|
|
})
|
|
|
|
it('1.2 Verifies all 31 Druid overlays exist in both public/ and samples/d2-packs/', () => {
|
|
for (const o of DRUID_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} must be > 1000 bytes`).toBeGreaterThan(1000)
|
|
|
|
const buf = fs.readFileSync(pngPath)
|
|
expect(buf.readUInt32BE(0)).toBe(0x89504e47) // PNG signature
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 2: Metadata Registry & Contract Verification
|
|
// =========================================================================
|
|
describe('Suite 2: Metadata Registry & Typed Contract Integrity', () => {
|
|
it('2.1 Verifies all Druid missiles registered in MISSILE_METAS with matching properties', () => {
|
|
for (const m of DRUID_MISSILES) {
|
|
const registered = MISSILE_METAS[m.name]
|
|
expect(registered, `MISSILE_METAS missing '${m.name}'`).toBeDefined()
|
|
expect(registered!.name).toBe(m.name)
|
|
expect(registered!.celFile).toBe(m.celFile)
|
|
expect(registered!.width).toBeGreaterThan(0)
|
|
expect(registered!.height).toBeGreaterThan(0)
|
|
expect(registered!.directions).toBeGreaterThan(0)
|
|
expect(registered!.framesPerDirection).toBeGreaterThan(0)
|
|
expect(registered!.animSpeed).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('2.2 Verifies all Druid overlays registered in OVERLAY_METAS with matching properties', () => {
|
|
for (const o of DRUID_OVERLAYS) {
|
|
const registered = OVERLAY_METAS[o.name]
|
|
expect(registered, `OVERLAY_METAS missing '${o.name}'`).toBeDefined()
|
|
expect(registered!.name).toBe(o.name)
|
|
expect(registered!.celFile).toBe(o.celFile)
|
|
expect(registered!.width).toBeGreaterThan(0)
|
|
expect(registered!.height).toBeGreaterThan(0)
|
|
expect(registered!.frames).toBeGreaterThan(0)
|
|
expect(registered!.box).toBeDefined()
|
|
expect(registered!.box.width).toBeGreaterThan(0)
|
|
expect(registered!.box.height).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('2.3 Verifies Druid overlay back layers have preDraw: true and front layers have preDraw: false', () => {
|
|
for (const o of DRUID_OVERLAYS) {
|
|
const meta = OVERLAY_METAS[o.name]!
|
|
if (o.name.includes('back')) {
|
|
expect(meta.preDraw, `${o.name} must have preDraw: true`).toBe(true)
|
|
} else {
|
|
expect(meta.preDraw, `${o.name} must have preDraw: false`).toBe(false)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 3: RGBA Color Type & Transparency Format
|
|
// =========================================================================
|
|
describe('Suite 3: RGBA Format & Transparency Decoding', () => {
|
|
it('3.1 Validates 32-bit RGBA colorType 6 and bitDepth 8 for Druid missiles', () => {
|
|
for (const m of DRUID_MISSILES) {
|
|
const filePath = path.join(publicMissilesDir, `${m.name}.png`)
|
|
const buf = fs.readFileSync(filePath)
|
|
const { width, height, bitDepth, colorType } = decodeRgbaPng(buf)
|
|
|
|
expect(bitDepth).toBe(8)
|
|
expect(colorType).toBe(6) // RGBA
|
|
expect(width).toBe(m.meta.width)
|
|
expect(height).toBe(m.meta.height)
|
|
}
|
|
})
|
|
|
|
it('3.2 Validates 32-bit RGBA colorType 6 and bitDepth 8 for Druid overlays', () => {
|
|
for (const o of DRUID_OVERLAYS) {
|
|
const filePath = path.join(publicOverlaysDir, `${o.name}.png`)
|
|
const buf = fs.readFileSync(filePath)
|
|
const { width, height, bitDepth, colorType } = decodeRgbaPng(buf)
|
|
|
|
expect(bitDepth).toBe(8)
|
|
expect(colorType).toBe(6) // RGBA
|
|
expect(width).toBe(o.meta.width)
|
|
expect(height).toBe(o.meta.height)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 4: Invariant & Baseline Preservation
|
|
// =========================================================================
|
|
describe('Suite 4: Architectural Invariants', () => {
|
|
it('4.1 BATCH1_SKILLS length remains strictly 38', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
|
|
it('4.2 Zero Runtime MPQ Invariant holds for Druid assets', () => {
|
|
// Verifies all metadata structures are pure JS objects and do not reference MPQ runtime handles
|
|
for (const m of DRUID_MISSILES) {
|
|
expect(typeof m.meta.name).toBe('string')
|
|
expect(Array.isArray(m.meta.groups)).toBe(true)
|
|
}
|
|
for (const o of DRUID_OVERLAYS) {
|
|
expect(typeof o.meta.name).toBe('string')
|
|
expect(Array.isArray(o.meta.groups)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
})
|