diablo2-web/tests/skills/ama/amazon-assets.test.ts

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)
})
})
})