test(inventory): track challenger M4 inventory audit suite
This commit is contained in:
parent
a46629cc11
commit
e7d9070bb8
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* tests/challenger-m4-inventory-audit.test.ts
|
||||
*
|
||||
* EMPIRICAL CHALLENGER ADVERSARIAL TEST HARNESS
|
||||
* Milestone M4 (Issue #149): Complete base item atlas resolution, bounding limits,
|
||||
* transformed item dyes, authentic charm aspect ratios, and fallback guarantees.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import {
|
||||
resolveItemSpriteRect,
|
||||
STARTER_BAG_ITEMS,
|
||||
STARTER_EQUIPPED_GEAR,
|
||||
} from '../src/ui/inventory.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { parseTable } from '../src/game/tables.ts'
|
||||
|
||||
describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
|
||||
const itemRects = BAKED_UI_MANIFEST.itemRects
|
||||
const atlasWidth = BAKED_UI_MANIFEST.atlasWidth
|
||||
const atlasHeight = BAKED_UI_MANIFEST.atlasHeight
|
||||
|
||||
describe('Objective 1: Atlas Bounding Limits & Integrity', () => {
|
||||
it('contains at least 399 sprite rects in BAKED_UI_MANIFEST.itemRects', () => {
|
||||
const keys = Object.keys(itemRects)
|
||||
expect(keys.length).toBeGreaterThanOrEqual(399)
|
||||
})
|
||||
|
||||
it('enforces that every sprite rect in itemRects is strictly within atlas bounds', () => {
|
||||
expect(atlasWidth).toBe(1024)
|
||||
expect(atlasHeight).toBeGreaterThanOrEqual(1024)
|
||||
|
||||
for (const [key, rect] of Object.entries(itemRects)) {
|
||||
expect(rect, `Sprite ${key} must be defined`).toBeDefined()
|
||||
expect(rect.w, `Sprite ${key} width must be > 0`).toBeGreaterThan(0)
|
||||
expect(rect.h, `Sprite ${key} height must be > 0`).toBeGreaterThan(0)
|
||||
expect(rect.x, `Sprite ${key} x must be >= 0`).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.y, `Sprite ${key} y must be >= 0`).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Strict boundary containment
|
||||
const right = rect.x + rect.w
|
||||
const bottom = rect.y + rect.h
|
||||
expect(
|
||||
right,
|
||||
`Sprite ${key} exceeds atlas width: right edge ${right} > atlasWidth ${atlasWidth}`,
|
||||
).toBeLessThanOrEqual(atlasWidth)
|
||||
expect(
|
||||
bottom,
|
||||
`Sprite ${key} exceeds atlas height: bottom edge ${bottom} > atlasHeight ${atlasHeight}`,
|
||||
).toBeLessThanOrEqual(atlasHeight)
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces that questRects are strictly within questsAtlas bounds', () => {
|
||||
const questRects = BAKED_UI_MANIFEST.questRects
|
||||
const qW = BAKED_UI_MANIFEST.questsAtlasWidth
|
||||
const qH = BAKED_UI_MANIFEST.questsAtlasHeight
|
||||
|
||||
expect(Object.keys(questRects).length).toBeGreaterThan(0)
|
||||
for (const [key, rect] of Object.entries(questRects)) {
|
||||
expect(rect.w).toBeGreaterThan(0)
|
||||
expect(rect.h).toBeGreaterThan(0)
|
||||
expect(rect.x).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.y).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.x + rect.w).toBeLessThanOrEqual(qW)
|
||||
expect(rect.y + rect.h).toBeLessThanOrEqual(qH)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Objective 2: Transformed Items (Dyes) Existence & Bounding', () => {
|
||||
const transformedKeys = [
|
||||
'invcap_cgrn', // Emerald Harlequin Crest Shako
|
||||
'invgth_dpur', // Dark Purple Gothic Plate
|
||||
'invtgl_lgry', // Light Grey Heavy Gloves
|
||||
'invvbl_blac', // Black Vampirefang Belt
|
||||
'invlbl_blac', // Black Light Belt
|
||||
]
|
||||
|
||||
it.each(transformedKeys)('transformed item sprite "%s" exists with non-zero dimensions inside atlas', (key) => {
|
||||
const rect = itemRects[key]
|
||||
expect(rect, `Transformed sprite "${key}" must exist in itemRects`).toBeDefined()
|
||||
expect(rect!.w, `Transformed sprite "${key}" width must be > 0`).toBeGreaterThan(0)
|
||||
expect(rect!.h, `Transformed sprite "${key}" height must be > 0`).toBeGreaterThan(0)
|
||||
expect(rect!.x + rect!.w).toBeLessThanOrEqual(atlasWidth)
|
||||
expect(rect!.y + rect!.h).toBeLessThanOrEqual(atlasHeight)
|
||||
})
|
||||
|
||||
it('resolves transformed dyed items via resolveItemSpriteRect with case-insensitivity', () => {
|
||||
const shako = resolveItemSpriteRect({ invFile: 'invcap', invtransform: 'cgrn' }, itemRects)
|
||||
expect(shako).toEqual(itemRects['invcap_cgrn'])
|
||||
|
||||
const shakoUpper = resolveItemSpriteRect({ invFile: 'INVCAP', invtransform: 'CGRN' }, itemRects)
|
||||
expect(shakoUpper).toEqual(itemRects['invcap_cgrn'])
|
||||
|
||||
const gothic = resolveItemSpriteRect({ invFile: 'invgth', invtransform: 'dpur' }, itemRects)
|
||||
expect(gothic).toEqual(itemRects['invgth_dpur'])
|
||||
|
||||
const gloves = resolveItemSpriteRect({ invFile: 'invtgl', invtransform: 'lgry' }, itemRects)
|
||||
expect(gloves).toEqual(itemRects['invtgl_lgry'])
|
||||
|
||||
const beltV = resolveItemSpriteRect({ invFile: 'invvbl', invtransform: 'blac' }, itemRects)
|
||||
expect(beltV).toEqual(itemRects['invvbl_blac'])
|
||||
|
||||
const beltL = resolveItemSpriteRect({ invFile: 'invlbl', invtransform: 'blac' }, itemRects)
|
||||
expect(beltL).toEqual(itemRects['invlbl_blac'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Objective 3: 100+ Base Items from weapons.txt, armor.txt, and misc.txt', () => {
|
||||
it('empirically audits 100+ base items from 1.13c MPQ data tables', async () => {
|
||||
const d2Path = join(process.cwd(), 'samples', 'd2')
|
||||
if (!existsSync(join(d2Path, 'Patch_D2.mpq'))) {
|
||||
throw new Error('Authoritative MPQ directory samples/d2 is missing or unmounted')
|
||||
}
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(m, await MpqArchive.open(await fileSource(join(d2Path, m))))
|
||||
}
|
||||
|
||||
let totalBaseItemsAudited = 0
|
||||
const auditedCodes = new Set<string>()
|
||||
|
||||
for (const tblFile of ['weapons.txt', 'armor.txt', 'misc.txt']) {
|
||||
const data = await archives.read(`data/global/excel/${tblFile}`)
|
||||
const tbl = parseTable(new TextDecoder().decode(data))
|
||||
|
||||
for (const row of tbl.rows) {
|
||||
const code = row['code']?.trim()
|
||||
if (!code || auditedCodes.has(code)) continue
|
||||
auditedCodes.add(code)
|
||||
|
||||
const invfile = row['invfile']?.trim()
|
||||
const name = row['name']?.trim()
|
||||
|
||||
// 1. Resolve with full item info
|
||||
const rectFull = resolveItemSpriteRect({ code, invFile: invfile, name }, itemRects)
|
||||
expect(
|
||||
rectFull,
|
||||
`Base item code="${code}" (invFile="${invfile}", name="${name}") from ${tblFile} must resolve`,
|
||||
).not.toBeNull()
|
||||
expect(rectFull!.w).toBeGreaterThan(0)
|
||||
expect(rectFull!.h).toBeGreaterThan(0)
|
||||
expect(rectFull!.x + rectFull!.w).toBeLessThanOrEqual(atlasWidth)
|
||||
expect(rectFull!.y + rectFull!.h).toBeLessThanOrEqual(atlasHeight)
|
||||
|
||||
// 2. Resolve by code alone (Tier 3 lookup in codeToInvFile)
|
||||
const rectByCode = resolveItemSpriteRect({ code }, itemRects)
|
||||
expect(
|
||||
rectByCode,
|
||||
`Base item code="${code}" must resolve without invFile`,
|
||||
).not.toBeNull()
|
||||
|
||||
// 3. Strict charm aspect ratio and ground truth assertions
|
||||
if (code === 'cm1') {
|
||||
expect(rectFull!.w).toBe(28)
|
||||
expect(rectFull!.h).toBe(28)
|
||||
expect(rectByCode!.h).toBe(28)
|
||||
} else if (code === 'cm2') {
|
||||
expect(rectFull!.w).toBe(28)
|
||||
expect(rectFull!.h).toBe(56)
|
||||
expect(rectByCode!.h).toBe(56)
|
||||
expect(rectFull).not.toEqual(itemRects['invwnd'])
|
||||
} else if (code === 'cm3') {
|
||||
expect(rectFull!.w).toBe(28)
|
||||
expect(rectFull!.h).toBe(84)
|
||||
expect(rectByCode!.h).toBe(84)
|
||||
expect(rectFull).not.toEqual(itemRects['invsst'])
|
||||
}
|
||||
|
||||
totalBaseItemsAudited++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Empirically audited ${totalBaseItemsAudited} unique base items from MPQ data tables.`)
|
||||
expect(totalBaseItemsAudited).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Objective 4: Dimension Fallbacks Stress Testing', () => {
|
||||
it('w=1, h=2 resolves to 56px height', () => {
|
||||
const item1x2 = resolveItemSpriteRect({ invWidth: 1, invHeight: 2 }, itemRects)
|
||||
expect(item1x2).not.toBeNull()
|
||||
expect(item1x2!.h).toBe(56)
|
||||
expect(item1x2!.w).toBe(28)
|
||||
})
|
||||
|
||||
it('w=1, h=3 resolves to 84px height', () => {
|
||||
// Non-charm generic 1x3
|
||||
const generic1x3 = resolveItemSpriteRect({ invWidth: 1, invHeight: 3 }, itemRects)
|
||||
expect(generic1x3).not.toBeNull()
|
||||
expect(generic1x3!.h).toBe(84)
|
||||
expect(generic1x3!.w).toBe(28)
|
||||
|
||||
// Charm-designated 1x3
|
||||
const charm1x3 = resolveItemSpriteRect({ name: 'Mystic Grand Charm', invWidth: 1, invHeight: 3 }, itemRects)
|
||||
expect(charm1x3).not.toBeNull()
|
||||
expect(charm1x3!.h).toBe(84)
|
||||
expect(charm1x3!.w).toBe(28)
|
||||
expect(charm1x3).toEqual(itemRects['invch3'])
|
||||
})
|
||||
|
||||
it('exhaustively verifies all standard grid dimension fallbacks', () => {
|
||||
const testCases = [
|
||||
{ w: 1, h: 1, expectedH: 28 },
|
||||
{ w: 1, h: 2, expectedH: 56 },
|
||||
{ w: 1, h: 3, expectedH: 84 },
|
||||
{ w: 1, h: 4, expectedH: 112 },
|
||||
{ w: 2, h: 1, expectedH: 28 },
|
||||
{ w: 2, h: 2, expectedH: 56 },
|
||||
{ w: 2, h: 3, expectedH: 84 },
|
||||
{ w: 2, h: 4, expectedH: 112 },
|
||||
]
|
||||
|
||||
for (const { w, h, expectedH } of testCases) {
|
||||
const res = resolveItemSpriteRect({ invWidth: w, invHeight: h }, itemRects)
|
||||
expect(res, `Fallback for w=${w}, h=${h} must resolve`).not.toBeNull()
|
||||
expect(res!.h, `Fallback for w=${w}, h=${h} must match height ${expectedH}`).toBe(expectedH)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adversarial & Boundary Edge Cases', () => {
|
||||
it('handles empty item object safely without crashing', () => {
|
||||
const empty = resolveItemSpriteRect({}, itemRects)
|
||||
expect(empty).not.toBeNull()
|
||||
expect(empty!.w).toBeGreaterThan(0)
|
||||
expect(empty!.h).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('handles unmapped item with unknown code but valid dimensions', () => {
|
||||
const unknownItem = resolveItemSpriteRect(
|
||||
{ code: 'nonexistent_code_9999', invWidth: 1, invHeight: 2 },
|
||||
itemRects,
|
||||
)
|
||||
expect(unknownItem).not.toBeNull()
|
||||
expect(unknownItem!.h).toBe(56)
|
||||
})
|
||||
|
||||
it('handles adversarial keys without prototype pollution or exceptions', () => {
|
||||
const badKeys = ['__proto__', 'constructor', 'toString', 'valueOf', '<script>', "'; DROP TABLE;"]
|
||||
for (const bad of badKeys) {
|
||||
expect(() => resolveItemSpriteRect({ code: bad }, itemRects)).not.toThrow()
|
||||
expect(() => resolveItemSpriteRect({ invFile: bad }, itemRects)).not.toThrow()
|
||||
expect(() => resolveItemSpriteRect({ invtransform: bad }, itemRects)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('confirms starter inventory and equipped gear resolution integrity', () => {
|
||||
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) {
|
||||
if (!item) continue
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Equipped item ${slot} (${item.name}) must resolve`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
expect(rect!.h).toBeGreaterThan(0)
|
||||
expect(rect!.x + rect!.w).toBeLessThanOrEqual(atlasWidth)
|
||||
expect(rect!.y + rect!.h).toBeLessThanOrEqual(atlasHeight)
|
||||
}
|
||||
|
||||
for (const { item, col, row } of STARTER_BAG_ITEMS) {
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Bag item at (${col}, ${row}) (${item.name}) must resolve`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
expect(rect!.h).toBeGreaterThan(0)
|
||||
expect(rect!.x + rect!.w).toBeLessThanOrEqual(atlasWidth)
|
||||
expect(rect!.y + rect!.h).toBeLessThanOrEqual(atlasHeight)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue