332 lines
14 KiB
TypeScript
332 lines
14 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,
|
|
HOWL_META,
|
|
SHOUT_META,
|
|
BATTLECRY_META,
|
|
BATTLEORDERS_META,
|
|
BATTLECOMMAND_META,
|
|
WARCRY_META,
|
|
GRIMWARDSMALLSTART_META,
|
|
GRIMWARDSMALL_META,
|
|
GRIMWARDSMALLSTOP_META,
|
|
GRIMWARDMEDIUMSTART_META,
|
|
GRIMWARDMEDIUM_META,
|
|
GRIMWARDMEDIUMSTOP_META,
|
|
GRIMWARDLARGESTART_META,
|
|
GRIMWARDLARGE_META,
|
|
GRIMWARDLARGESTOP_META,
|
|
type MissileMeta,
|
|
} from '../../../src/render/missiles-meta.ts'
|
|
import {
|
|
OVERLAY_METAS,
|
|
ALL_OVERLAY_NAMES,
|
|
SHOUTSTART_OVERLAY_META,
|
|
SHOUT_OVERLAY_META,
|
|
BATTLECRY_OVERLAY_META,
|
|
BATTLEORDERSCAST_OVERLAY_META,
|
|
BATTLEORDERS_OVERLAY_META,
|
|
BATTLECOMMANDCAST_OVERLAY_META,
|
|
BATTLECOMMAND_OVERLAY_META,
|
|
BERSERKFRONT_OVERLAY_META,
|
|
FRENZY_OVERLAY_META,
|
|
WARCRY_OVERLAY_META,
|
|
TAUNT_OVERLAY_META,
|
|
STUN_OVERLAY_META,
|
|
type OverlayMeta,
|
|
} from '../../../src/render/overlays-meta.ts'
|
|
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
|
|
|
|
// Strict PNG RGBA Decoder (validates colorType === 6, bitDepth === 8, and pixel transparency)
|
|
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 M16 — Barbarian 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 BARBARIAN_MISSILES = [
|
|
{ name: 'howl', celFile: 'BAYellShockWave01', meta: HOWL_META, trans: 1 },
|
|
{ name: 'shout', celFile: 'BAYellShockWave01', meta: SHOUT_META, trans: 1 },
|
|
{ name: 'battlecry', celFile: 'BAYellShockWave01', meta: BATTLECRY_META, trans: 1 },
|
|
{ name: 'battleorders', celFile: 'BAYellShockWave01', meta: BATTLEORDERS_META, trans: 1 },
|
|
{ name: 'battlecommand', celFile: 'BAYellShockWave01', meta: BATTLECOMMAND_META, trans: 1 },
|
|
{ name: 'warcry', celFile: 'WarcryShockwave', meta: WARCRY_META, trans: 1 },
|
|
{ name: 'grimwardsmallstart', celFile: 'GrimWardSmallStart', meta: GRIMWARDSMALLSTART_META, trans: 0 },
|
|
{ name: 'grimwardsmall', celFile: 'GrimWardSmall', meta: GRIMWARDSMALL_META, trans: 0 },
|
|
{ name: 'grimwardsmallstop', celFile: 'GrimWardSmallStop', meta: GRIMWARDSMALLSTOP_META, trans: 0 },
|
|
{ name: 'grimwardmediumstart', celFile: 'GrimWardMediumStart', meta: GRIMWARDMEDIUMSTART_META, trans: 0 },
|
|
{ name: 'grimwardmedium', celFile: 'GrimWardMedium', meta: GRIMWARDMEDIUM_META, trans: 0 },
|
|
{ name: 'grimwardmediumstop', celFile: 'GrimWardMediumStop', meta: GRIMWARDMEDIUMSTOP_META, trans: 0 },
|
|
{ name: 'grimwardlargestart', celFile: 'GrimWardLargeStart', meta: GRIMWARDLARGESTART_META, trans: 0 },
|
|
{ name: 'grimwardlarge', celFile: 'GrimWardLarge', meta: GRIMWARDLARGE_META, trans: 0 },
|
|
{ name: 'grimwardlargestop', celFile: 'GrimWardLargeStop', meta: GRIMWARDLARGESTOP_META, trans: 0 },
|
|
] as const
|
|
|
|
const BARBARIAN_OVERLAYS = [
|
|
{ name: 'shoutstart', celFile: 'ShoutStart', meta: SHOUTSTART_OVERLAY_META },
|
|
{ name: 'shout', celFile: 'Shout', meta: SHOUT_OVERLAY_META },
|
|
{ name: 'battlecry', celFile: 'BattleCry', meta: BATTLECRY_OVERLAY_META },
|
|
{ name: 'battleorderscast', celFile: 'BattleOrdersStart', meta: BATTLEORDERSCAST_OVERLAY_META },
|
|
{ name: 'battleorders', celFile: 'BattleOrders', meta: BATTLEORDERS_OVERLAY_META },
|
|
{ name: 'battlecommandcast', celFile: 'BattleCommandStart', meta: BATTLECOMMANDCAST_OVERLAY_META },
|
|
{ name: 'battlecommand', celFile: 'BattleCommand', meta: BATTLECOMMAND_OVERLAY_META },
|
|
{ name: 'berserkfront', celFile: 'BerserkOverlay', meta: BERSERKFRONT_OVERLAY_META },
|
|
{ name: 'frenzy', celFile: 'Frenzy', meta: FRENZY_OVERLAY_META },
|
|
{ name: 'warcry', celFile: 'WarcryHit', meta: WARCRY_OVERLAY_META },
|
|
] as const
|
|
|
|
// =========================================================================
|
|
// Suite 1: Physical Asset Existence & Dual Mirroring
|
|
// =========================================================================
|
|
describe('Suite 1: Physical Asset Existence & Dual Mirroring', () => {
|
|
it('1.1 Verifies all 15 Barbarian missiles exist in both public/ and samples/d2-packs/', () => {
|
|
for (const m of BARBARIAN_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 10 Barbarian overlays exist in both public/ and samples/d2-packs/', () => {
|
|
for (const o of BARBARIAN_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 Barbarian missiles registered in MISSILE_METAS with matching properties', () => {
|
|
for (const m of BARBARIAN_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!.groups.length).toBe(registered!.directions)
|
|
|
|
// Verify CelFile alias lookup
|
|
const celLookup = MISSILE_METAS[m.celFile]
|
|
expect(celLookup, `CelFile lookup missing for ${m.celFile}`).toBeDefined()
|
|
expect(celLookup!.celFile).toBe(m.celFile)
|
|
}
|
|
})
|
|
|
|
it('2.2 Verifies all Barbarian overlays registered in OVERLAY_METAS and ALL_OVERLAY_NAMES', () => {
|
|
for (const o of BARBARIAN_OVERLAYS) {
|
|
expect(ALL_OVERLAY_NAMES).toContain(o.name)
|
|
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!.frames).toBeGreaterThan(0)
|
|
expect(registered!.groups.length).toBe(registered!.directions)
|
|
expect(registered!.box).toBeDefined()
|
|
expect(registered!.box.width).toBeGreaterThan(0)
|
|
expect(registered!.box.height).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('2.3 Verifies Taunt (#137) and Stun (#139) alias contracts', () => {
|
|
expect(TAUNT_OVERLAY_META).toBeDefined()
|
|
expect(TAUNT_OVERLAY_META.celFile).toBe('CurseConfuseEffect')
|
|
expect(OVERLAY_METAS['taunt']).toBe(TAUNT_OVERLAY_META)
|
|
|
|
expect(STUN_OVERLAY_META).toBeDefined()
|
|
expect(STUN_OVERLAY_META.celFile).toBe('Stun')
|
|
expect(OVERLAY_METAS['stun']).toBe(STUN_OVERLAY_META)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 3: Transparency & Edge Artifact Audit (Zero Dark Borders)
|
|
// =========================================================================
|
|
describe('Suite 3: Transparency & Alpha Ramp Audit (No Black Box Fringe)', () => {
|
|
it('3.1 Audits Warcry shockwave missiles for true Trans=1 additive blending', () => {
|
|
for (const m of ['howl', 'shout', 'battlecry', 'battleorders', 'battlecommand', 'warcry']) {
|
|
const pngPath = path.join(publicMissilesDir, `${m}.png`)
|
|
const buf = fs.readFileSync(pngPath)
|
|
const { width, height, pixels } = decodeRgbaPng(buf)
|
|
|
|
let transparentCount = 0
|
|
let opaqueOrSemiCount = 0
|
|
let blackOpaqueCount = 0
|
|
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const r = pixels[i]!
|
|
const g = pixels[i + 1]!
|
|
const b = pixels[i + 2]!
|
|
const a = pixels[i + 3]!
|
|
|
|
if (a === 0) {
|
|
transparentCount++
|
|
} else {
|
|
opaqueOrSemiCount++
|
|
// Fringe dark border check: if max(r,g,b) <= 4, alpha MUST be 0
|
|
if (Math.max(r, g, b) <= 4 && a > 0) {
|
|
blackOpaqueCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(transparentCount, `${m} must have transparent pixels`).toBeGreaterThan(0)
|
|
expect(opaqueOrSemiCount, `${m} must have non-transparent pixels`).toBeGreaterThan(0)
|
|
expect(blackOpaqueCount, `${m} has black fringe pixels with non-zero alpha`).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('3.2 Audits Barbarian overlays for authentic Trans=3 blending (no background border smudge)', () => {
|
|
for (const o of BARBARIAN_OVERLAYS) {
|
|
const pngPath = path.join(publicOverlaysDir, `${o.name}.png`)
|
|
const buf = fs.readFileSync(pngPath)
|
|
const { width, height, pixels } = decodeRgbaPng(buf)
|
|
|
|
let transparentCount = 0
|
|
let opaqueOrSemiCount = 0
|
|
let blackOpaqueCount = 0
|
|
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const r = pixels[i]!
|
|
const g = pixels[i + 1]!
|
|
const b = pixels[i + 2]!
|
|
const a = pixels[i + 3]!
|
|
|
|
if (a === 0) {
|
|
transparentCount++
|
|
} else {
|
|
opaqueOrSemiCount++
|
|
if (Math.max(r, g, b) <= 4 && a > 0) {
|
|
blackOpaqueCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(transparentCount, `${o.name} must have transparent pixels`).toBeGreaterThan(0)
|
|
expect(opaqueOrSemiCount, `${o.name} must have rendered pixels`).toBeGreaterThan(0)
|
|
expect(blackOpaqueCount, `${o.name} must have 0 dark opaque border pixels`).toBe(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 4: Invariant & Regression Guardrails
|
|
// =========================================================================
|
|
describe('Suite 4: Invariants & Regression Guardrails', () => {
|
|
it('4.1 Enforces BATCH1_SKILLS length invariant = exactly 38', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
|
|
it('4.2 Enforces all 15 Barbarian missiles have valid bounding frame boxes and non-negative anchors', () => {
|
|
for (const m of BARBARIAN_MISSILES) {
|
|
const meta = m.meta
|
|
for (const dirGroup of meta.groups) {
|
|
for (const frame of dirGroup) {
|
|
const [x, y, w, h, ax, ay] = frame
|
|
expect(x).toBeGreaterThanOrEqual(0)
|
|
expect(y).toBeGreaterThanOrEqual(0)
|
|
expect(w).toBeGreaterThan(0)
|
|
expect(h).toBeGreaterThan(0)
|
|
expect(x + w).toBeLessThanOrEqual(meta.width)
|
|
expect(y + h).toBeLessThanOrEqual(meta.height)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|