diff --git a/tests/challenger-m4-inventory-audit.test.ts b/tests/challenger-m4-inventory-audit.test.ts new file mode 100644 index 0000000..d5e681c --- /dev/null +++ b/tests/challenger-m4-inventory-audit.test.ts @@ -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() + + 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', '