407 lines
17 KiB
TypeScript
407 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,
|
|
MAGICARROW_META,
|
|
FIREARROW_META,
|
|
ICEARROW_META,
|
|
ARROW_META,
|
|
LIGHTNINGJAVELIN_META,
|
|
BOLT_META,
|
|
COLDARROW_META,
|
|
EXPLODINGARROW_META,
|
|
EXPLODINGARROWEXP_META,
|
|
ICEARROWEXPLODE_META,
|
|
FREEZINGARROW_META,
|
|
GUIDEDARROW_META,
|
|
STRAFEARROW_META,
|
|
STRAFEBOLT_META,
|
|
IMMOLATIONARROW_META,
|
|
IMMOLATIONARROWEXP_META,
|
|
IMMOLATIONFIRE_META,
|
|
POISONJAV_META,
|
|
POISONJAVCLOUD_META,
|
|
CHARGEDSTRIKEBOLT_META,
|
|
PLAGUEJAVELIN_META,
|
|
PLAGUEJAVCLOUD_META,
|
|
LIGHTNINGFURY_META,
|
|
FURYLIGHTNING_META,
|
|
type MissileMeta,
|
|
} from '../../../src/render/missiles-meta.ts'
|
|
import {
|
|
OVERLAY_METAS,
|
|
ALL_OVERLAY_NAMES,
|
|
CAST_INNERSIGHT_OVERLAY_META,
|
|
INNERSIGHT_OVERLAY_META,
|
|
CAST_SLOWMISSILES_OVERLAY_META,
|
|
HANDOFATHENA_OVERLAY_META,
|
|
VALKYRIESTART_OVERLAY_META,
|
|
VALKYRIE_OVERLAY_META,
|
|
DOPLLEZON_APPEAR_OVERLAY_META,
|
|
DOUBLEDAMAGE1_OVERLAY_META,
|
|
DOUBLEDAMAGE2_OVERLAY_META,
|
|
DOUBLEDAMAGE3_OVERLAY_META,
|
|
type OverlayMeta,
|
|
} from '../../../src/render/overlays-meta.ts'
|
|
import { BATCH1_SKILLS, ISO_GROUND_ASPECT_RATIO } 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 M11 — Amazon 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 entitiesDir = path.resolve(__dirname, '../../../samples/d2-packs/entities')
|
|
|
|
const AMAZON_MISSILES = [
|
|
{ name: 'magicarrow', celFile: 'SafeArrow', meta: MAGICARROW_META, trans: 1 },
|
|
{ name: 'firearrow', celFile: 'FireArrow', meta: FIREARROW_META, trans: 1 },
|
|
{ name: 'icearrow', celFile: 'IceArrow', meta: ICEARROW_META, trans: 1 },
|
|
{ name: 'arrow', celFile: 'Arrow', meta: ARROW_META, trans: 0 },
|
|
{ name: 'lightningjavelin', celFile: 'LightningJavelin', meta: LIGHTNINGJAVELIN_META, trans: 1 },
|
|
{ name: 'bolt', celFile: 'XBowBolt', meta: BOLT_META, trans: 0 },
|
|
{ name: 'coldarrow', celFile: 'IceArrow', meta: COLDARROW_META, trans: 1 },
|
|
{ name: 'explodingarrow', celFile: 'FireArrow', meta: EXPLODINGARROW_META, trans: 1 },
|
|
{ name: 'explodingarrowexp', celFile: 'ExpArrowExplode', meta: EXPLODINGARROWEXP_META, trans: 1 },
|
|
{ name: 'icearrowexplode', celFile: 'IceArrowExplode', meta: ICEARROWEXPLODE_META, trans: 1 },
|
|
{ name: 'freezingarrow', celFile: 'IceArrow', meta: FREEZINGARROW_META, trans: 1 },
|
|
{ name: 'guidedarrow', celFile: 'SafeArrow', meta: GUIDEDARROW_META, trans: 1 },
|
|
{ name: 'strafearrow', celFile: 'Arrow', meta: STRAFEARROW_META, trans: 0 },
|
|
{ name: 'strafebolt', celFile: 'XBowBolt', meta: STRAFEBOLT_META, trans: 0 },
|
|
{ name: 'immolationarrow', celFile: 'FireArrow', meta: IMMOLATIONARROW_META, trans: 1 },
|
|
{ name: 'immolationarrowexp', celFile: 'ExpArrowExplode', meta: IMMOLATIONARROWEXP_META, trans: 1 },
|
|
{ name: 'immolationfire', celFile: 'groundFireBig', meta: IMMOLATIONFIRE_META, trans: 1 },
|
|
{ name: 'poisonjav', celFile: 'Javelin', meta: POISONJAV_META, trans: 0 },
|
|
{ name: 'poisonjavcloud', celFile: 'PoisonSparks', meta: POISONJAVCLOUD_META, trans: 1 },
|
|
{ name: 'chargedstrikebolt', celFile: 'ChargedBolt', meta: CHARGEDSTRIKEBOLT_META, trans: 1 },
|
|
{ name: 'plaguejavelin', celFile: 'Javelin', meta: PLAGUEJAVELIN_META, trans: 0 },
|
|
{ name: 'plaguejavcloud', celFile: 'PoisonSparks', meta: PLAGUEJAVCLOUD_META, trans: 1 },
|
|
{ name: 'lightningfury', celFile: 'LightningJavelin', meta: LIGHTNINGFURY_META, trans: 1 },
|
|
{ name: 'furylightning', celFile: 'LightningStrike', meta: FURYLIGHTNING_META, trans: 1 },
|
|
] as const
|
|
|
|
const AMAZON_OVERLAYS = [
|
|
{ name: 'cast_innersight', celFile: 'EOZCast', meta: CAST_INNERSIGHT_OVERLAY_META },
|
|
{ name: 'innersight', celFile: 'HardenArmor', meta: INNERSIGHT_OVERLAY_META },
|
|
{ name: 'cast_slowmissiles', celFile: 'HOAcast', meta: CAST_SLOWMISSILES_OVERLAY_META },
|
|
{ name: 'handofathena', celFile: 'HOAmissileExplode', meta: HANDOFATHENA_OVERLAY_META },
|
|
{ name: 'valkyriestart', celFile: 'ValkarieEmerge', meta: VALKYRIESTART_OVERLAY_META },
|
|
{ name: 'valkyrie', celFile: 'ValkarieGlow', meta: VALKYRIE_OVERLAY_META },
|
|
{ name: 'dopllezon_appear', celFile: 'DopplezonAppear', meta: DOPLLEZON_APPEAR_OVERLAY_META },
|
|
{ name: 'doubledamage1', celFile: 'impacting01', meta: DOUBLEDAMAGE1_OVERLAY_META },
|
|
{ name: 'doubledamage2', celFile: 'impacting02', meta: DOUBLEDAMAGE2_OVERLAY_META },
|
|
{ name: 'doubledamage3', celFile: 'impacting03', meta: DOUBLEDAMAGE3_OVERLAY_META },
|
|
] as const
|
|
|
|
// =========================================================================
|
|
// Suite 1: Physical Asset Existence & File Mirroring
|
|
// =========================================================================
|
|
describe('Suite 1: Physical Asset Existence & Dual Mirroring', () => {
|
|
it('1.1 Verifies all 24 Amazon missiles exist in both public/ and samples/d2-packs/', () => {
|
|
for (const m of AMAZON_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 Amazon overlays exist in both public/ and samples/d2-packs/', () => {
|
|
for (const o of AMAZON_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
|
|
}
|
|
}
|
|
})
|
|
|
|
it('1.3 Verifies Valkyrie monster entity files exist in samples/d2-packs/entities/', () => {
|
|
const r8Path = path.join(entitiesDir, 'monster-vk-hth.r8')
|
|
const jsonPath = path.join(entitiesDir, 'monster-vk-hth.json')
|
|
const indexPath = path.join(entitiesDir, 'index.json')
|
|
|
|
expect(fs.existsSync(r8Path), `Missing ${r8Path}`).toBe(true)
|
|
expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
|
|
expect(fs.existsSync(indexPath), `Missing ${indexPath}`).toBe(true)
|
|
|
|
const r8Stat = fs.statSync(r8Path)
|
|
expect(r8Stat.size).toBeGreaterThan(100000)
|
|
|
|
const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
|
expect(json.token).toBe('VK')
|
|
expect(json.weapon).toBe('hth')
|
|
expect(json.schema).toBe(2)
|
|
|
|
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
|
|
const vkEntry = index.entities.find((e: any) => e.key === 'vk-hth')
|
|
expect(vkEntry, 'vk-hth must be indexed in entities/index.json').toBeDefined()
|
|
expect(vkEntry.token).toBe('VK')
|
|
expect(vkEntry.weapon).toBe('hth')
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 2: Metadata Registry & Frame Geometry Invariants
|
|
// =========================================================================
|
|
describe('Suite 2: Metadata Registry & Frame Geometry Invariants', () => {
|
|
it('2.1 Schema validation for all Amazon missiles in MISSILE_METAS', () => {
|
|
for (const m of AMAZON_MISSILES) {
|
|
const meta = MISSILE_METAS[m.name]
|
|
expect(meta, `MISSILE_METAS must register ${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).toBeGreaterThanOrEqual(1)
|
|
expect(meta.framesPerDirection).toBeGreaterThanOrEqual(1)
|
|
expect(meta.animSpeed).toBeGreaterThan(0)
|
|
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(Number.isFinite(x) && !Number.isNaN(x)).toBe(true)
|
|
expect(Number.isFinite(y) && !Number.isNaN(y)).toBe(true)
|
|
expect(w).toBeGreaterThan(0)
|
|
expect(h).toBeGreaterThan(0)
|
|
expect(x + w).toBeLessThanOrEqual(meta.width)
|
|
expect(y + h).toBeLessThanOrEqual(meta.height)
|
|
expect(Number.isFinite(ax) && !Number.isNaN(ax)).toBe(true)
|
|
expect(Number.isFinite(ay) && !Number.isNaN(ay)).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('2.2 Schema validation for all Amazon overlays in OVERLAY_METAS', () => {
|
|
for (const o of AMAZON_OVERLAYS) {
|
|
const meta = OVERLAY_METAS[o.name]
|
|
expect(meta, `OVERLAY_METAS must register ${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).toBeGreaterThanOrEqual(1)
|
|
expect(meta.frames).toBeGreaterThanOrEqual(1)
|
|
expect(meta.animRate).toBeGreaterThan(0)
|
|
expect(meta.trans).toBe(3)
|
|
expect(meta.groups.length).toBe(meta.directions)
|
|
|
|
// Valkyrie glow must be preDraw: true, all other Amazon overlays preDraw: false
|
|
if (o.name === 'valkyrie') {
|
|
expect(meta.preDraw, 'Valkyrie glow overlay must have preDraw === true').toBe(true)
|
|
} else {
|
|
expect(meta.preDraw, `${o.name} must have preDraw === false`).toBe(false)
|
|
}
|
|
|
|
for (const group of meta.groups) {
|
|
expect(group.length).toBe(meta.framesPerDirection)
|
|
for (const [x, y, w, h, ax, ay] of group) {
|
|
expect(Number.isFinite(x) && !Number.isNaN(x)).toBe(true)
|
|
expect(Number.isFinite(y) && !Number.isNaN(y)).toBe(true)
|
|
expect(w).toBeGreaterThan(0)
|
|
expect(h).toBeGreaterThan(0)
|
|
expect(x + w).toBeLessThanOrEqual(meta.width)
|
|
expect(y + h).toBeLessThanOrEqual(meta.height)
|
|
expect(Number.isFinite(ax) && !Number.isNaN(ax)).toBe(true)
|
|
expect(Number.isFinite(ay) && !Number.isNaN(ay)).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 3: Zero-Black-Border & Additive Blending Quality
|
|
// =========================================================================
|
|
describe('Suite 3: Zero-Black-Border & Additive Blending Quality', () => {
|
|
it('3.1 Verifies all baked Amazon missiles have 32-bit RGBA and zero dark boundary artifact pixels', () => {
|
|
for (const m of AMAZON_MISSILES) {
|
|
const pngPath = path.join(publicMissilesDir, `${m.name}.png`)
|
|
const buf = fs.readFileSync(pngPath)
|
|
const decoded = decodeRgbaPng(buf)
|
|
|
|
expect(decoded.colorType, `${m.name}.png must be 32-bit RGBA (colorType 6)`).toBe(6)
|
|
expect(decoded.bitDepth, `${m.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 maxVal = Math.max(r, g, b)
|
|
|
|
// Threshold invariant: For translucent missiles (trans: 1), max(r,g,b) <= 4 must have alpha === 0
|
|
if (m.trans === 1 && maxVal <= 4 && a > 0) {
|
|
blackOpaqueCount++
|
|
}
|
|
if (maxVal >= 20 && a > 0) {
|
|
visibleCount++
|
|
}
|
|
}
|
|
|
|
if (m.trans === 1) {
|
|
expect(blackOpaqueCount, `${m.name}.png contains ${blackOpaqueCount} dark boundary artifact pixels`).toBe(0)
|
|
}
|
|
expect(visibleCount, `${m.name}.png must contain visible spell pixels`).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('3.2 Verifies all baked Amazon overlays have 32-bit RGBA and zero dark boundary artifact pixels', () => {
|
|
for (const o of AMAZON_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 maxVal = Math.max(r, g, b)
|
|
|
|
// Threshold invariant: max(r,g,b) <= 4 must have alpha === 0
|
|
if (maxVal <= 4 && a > 0) {
|
|
blackOpaqueCount++
|
|
}
|
|
if (maxVal >= 20 && 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)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 4: Animation & Mode Tokens Invariants
|
|
// =========================================================================
|
|
describe('Suite 4: Animation & Mode Tokens Invariants', () => {
|
|
it('4.1 Valkyrie monster pack has valid animation clips and modes', () => {
|
|
const jsonPath = path.join(entitiesDir, 'monster-vk-hth.json')
|
|
const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
|
|
|
const expectedClips = ['nu', 'wl', 'rn', 'a1', 'gh', 'dd', 'dt']
|
|
for (const clip of expectedClips) {
|
|
expect(json.clips[clip], `Valkyrie must contain clip '${clip}'`).toBeDefined()
|
|
expect(json.clips[clip].directions).toBeGreaterThanOrEqual(1)
|
|
expect(json.clips[clip].frames).toBeGreaterThanOrEqual(1)
|
|
}
|
|
|
|
expect(json.standFrames).toBeGreaterThanOrEqual(1)
|
|
expect(json.walkFrames).toBeGreaterThanOrEqual(1)
|
|
expect(json.width).toBe(4096)
|
|
expect(json.height).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Suite 5: Anti-Regression Invariants
|
|
// =========================================================================
|
|
describe('Suite 5: Anti-Regression Invariants', () => {
|
|
it('5.1 Enforces BATCH1_SKILLS length strictly at 38 skills', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
|
|
it('5.2 Enforces ISO_GROUND_ASPECT_RATIO === 0.5', () => {
|
|
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
|
|
})
|
|
})
|
|
})
|