import { beforeAll, describe, expect, it } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { execSync } from 'node:child_process' import { MISSILE_METAS } from '../src/render/missiles-meta.ts' import { OVERLAY_METAS, LIGHTNING_OVERLAY_META, FROZEN_ARMOR_OVERLAY_META, FROZEN_ARMOR_HIT_OVERLAY_META, ENERGY_SHIELD_OVERLAY_META, ENERGY_SHIELD_HIT_OVERLAY_META, THUNDERSTORM_CAST_OVERLAY_META, THUNDERSTORM_BACK_OVERLAY_META, ENCHANT_OVERLAY_META, ICE_EXPLODE_OVERLAY_META, } from '../src/render/overlays-meta.ts' describe('Milestone M0 — 9 Sorceress Skill & Hit Overlays Parity', () => { const publicDir = path.resolve(__dirname, '../public/overlays') const packDir = path.resolve(__dirname, '../samples/d2-packs/overlays') beforeAll(() => { const hasPacks = fs.existsSync(packDir) if (!hasPacks) { throw new Error( '[Preflight] Required asset directory missing in samples/d2-packs/overlays. ' + 'When running in a git worktree, symlink samples/d2-packs from the main repository:\n' + ' ln -s /samples/d2-packs samples/d2-packs' ) } }) const TARGETS = [ { name: 'lightning', meta: LIGHTNING_OVERLAY_META, celFile: 'ShockHitSm', frames: 20, trans: 3, preDraw: false }, { name: 'frozenarmor', meta: FROZEN_ARMOR_OVERLAY_META, celFile: 'FrozenArmor', frames: 24, trans: 3, preDraw: false }, { name: 'frozenarmor_hit', meta: FROZEN_ARMOR_HIT_OVERLAY_META, celFile: 'AuraResistColdCast', frames: 11, trans: 3, preDraw: true }, { name: 'energyshield', meta: ENERGY_SHIELD_OVERLAY_META, celFile: 'ManaShieldBall', frames: 8, trans: 5, preDraw: false }, { name: 'energyshieldhit0', meta: ENERGY_SHIELD_HIT_OVERLAY_META, celFile: 'ManaShieldDir00', frames: 9, trans: 3, preDraw: false }, { name: 'thunderstormcast', meta: THUNDERSTORM_CAST_OVERLAY_META, celFile: 'ThunderstormCast', frames: 10, trans: 3, preDraw: false }, { name: 'thunderstormback', meta: THUNDERSTORM_BACK_OVERLAY_META, celFile: 'ThunderstormCast_operate', frames: 19, trans: 3, preDraw: true }, { name: 'enchant', meta: ENCHANT_OVERLAY_META, celFile: 'FireEnchant', frames: 17, trans: 3, preDraw: false }, { name: 'ice_explode', meta: ICE_EXPLODE_OVERLAY_META, celFile: 'IceExplode', frames: 16, trans: 3, preDraw: false }, ] it('1. Verifies physical PNG and JSON files exist in public/overlays/ and samples/d2-packs/overlays/', () => { for (const t of TARGETS) { for (const dir of [publicDir, packDir]) { const pngPath = path.join(dir, `${t.name}.png`) const jsonPath = path.join(dir, `${t.name}.json`) expect(fs.existsSync(pngPath), `Missing ${pngPath}`).toBe(true) expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true) expect(fs.statSync(pngPath).size).toBeGreaterThan(1000) const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) expect(raw.name).toBe(t.name) expect(raw.celFile).toBe(t.celFile) expect(raw.frames).toBe(t.frames) expect(raw.trans).toBe(t.trans) expect(raw.preDraw).toBe(t.preDraw) } } }) it('2. Verifies typed metadata constants and properties match 1.13c ground truth', () => { for (const t of TARGETS) { expect(t.meta.name).toBe(t.name) expect(t.meta.celFile).toBe(t.celFile) expect(t.meta.frames).toBe(t.frames) expect(t.meta.framesPerDirection).toBe(t.frames) expect(t.meta.directions).toBe(1) expect(t.meta.trans).toBe(t.trans) expect(t.meta.preDraw).toBe(t.preDraw) expect(t.meta.groups[0]?.length).toBe(t.frames) expect(t.meta.box.width).toBeGreaterThan(0) expect(t.meta.box.height).toBeGreaterThan(0) } }) it('3. Verifies OVERLAY_METAS maps primary keys, celFiles, and aliases cleanly', () => { expect(OVERLAY_METAS['lightning']).toBe(LIGHTNING_OVERLAY_META) expect(OVERLAY_METAS['ShockHitSm']).toBe(LIGHTNING_OVERLAY_META) expect(OVERLAY_METAS['holyshockhit']).toBe(LIGHTNING_OVERLAY_META) expect(OVERLAY_METAS['frozenarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) expect(OVERLAY_METAS['shiverarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) expect(OVERLAY_METAS['chillarmor']).toBe(FROZEN_ARMOR_OVERLAY_META) expect(OVERLAY_METAS['frozenarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) expect(OVERLAY_METAS['cast_shivers']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) expect(OVERLAY_METAS['shiverarmor_hit']).toBe(FROZEN_ARMOR_HIT_OVERLAY_META) expect(OVERLAY_METAS['energyshield']).toBe(ENERGY_SHIELD_OVERLAY_META) expect(OVERLAY_METAS['energyshieldhit0']).toBe(ENERGY_SHIELD_HIT_OVERLAY_META) expect(OVERLAY_METAS['thunderstormcast']).toBe(THUNDERSTORM_CAST_OVERLAY_META) expect(OVERLAY_METAS['thunderstormback']).toBe(THUNDERSTORM_BACK_OVERLAY_META) expect(OVERLAY_METAS['enchant']).toBe(ENCHANT_OVERLAY_META) expect(OVERLAY_METAS['ice_explode']).toBe(ICE_EXPLODE_OVERLAY_META) }) it('4. Verifies package.json contains "pack:overlays" script and execution succeeds', () => { const pkgPath = path.resolve(__dirname, '../package.json') const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) expect(pkg.scripts, 'package.json missing scripts section').toBeDefined() expect(pkg.scripts['pack:overlays'], 'package.json missing pack:overlays').toBe('tsx scripts/pack-overlays.ts') expect(pkg.scripts['pack:missiles'], 'package.json missing pack:missiles').toBe('tsx scripts/pack-missiles.ts') const hasD2 = fs.existsSync(path.resolve(__dirname, '../samples/d2/d2data.mpq')) || fs.existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq') if (!hasD2) { throw new Error( '[Preflight] Cannot run pack:overlays: samples/d2/d2data.mpq is missing. ' + 'Please symlink samples/d2 to your Diablo II 1.13c installation directory.' ) } const tmpOut = fs.mkdtempSync(path.join(os.tmpdir(), 'd2-overlay-pack-test-')) try { // Execute npm run pack:overlays and verify zero exit code & console logging const output = execSync('npm run pack:overlays', { cwd: path.resolve(__dirname, '..'), encoding: 'utf-8', timeout: 30000, env: { ...process.env, D2_PACK_OUT_DIR: tmpOut }, }) expect(output).toContain('[Overlay Packer]') expect(output).toContain('overlays-meta.ts') expect(fs.existsSync(path.join(tmpOut, 'public/overlays/teleport.png'))).toBe(true) expect(fs.existsSync(path.join(tmpOut, 'samples/d2-packs/overlays/teleport.png'))).toBe(true) expect(fs.existsSync(path.join(tmpOut, 'src/render/overlays-meta.ts'))).toBe(true) } finally { fs.rmSync(tmpOut, { recursive: true, force: true }) } }) it('5. Verifies CelFile casing lookups in OVERLAY_METAS', () => { const CEL_FILE_CASING_TESTS = [ { key: 'ShockHitSm', expectedMeta: LIGHTNING_OVERLAY_META }, { key: 'FrozenArmor', expectedMeta: FROZEN_ARMOR_OVERLAY_META }, { key: 'AuraResistColdCast', expectedMeta: FROZEN_ARMOR_HIT_OVERLAY_META }, { key: 'ManaShieldBall', expectedMeta: ENERGY_SHIELD_OVERLAY_META }, { key: 'ManaShieldDir00', expectedMeta: ENERGY_SHIELD_HIT_OVERLAY_META }, { key: 'ThunderstormCast', expectedMeta: THUNDERSTORM_CAST_OVERLAY_META }, { key: 'ThunderstormCast_operate', expectedMeta: THUNDERSTORM_BACK_OVERLAY_META }, { key: 'FireEnchant', expectedMeta: ENCHANT_OVERLAY_META }, { key: 'IceExplode', expectedMeta: ICE_EXPLODE_OVERLAY_META }, ] for (const { key, expectedMeta } of CEL_FILE_CASING_TESTS) { expect(OVERLAY_METAS[key], `OVERLAY_METAS['${key}'] should resolve directly`).toBe(expectedMeta) expect(OVERLAY_METAS[key.toLowerCase()], `OVERLAY_METAS['${key.toLowerCase()}'] should resolve`).toBe(expectedMeta) } }) it('6. Verifies CelFile casing lookups in MISSILE_METAS', () => { const MISSILE_CASING_KEYS = [ 'ElectricNova', 'LightningStrike', 'LightningboltBig', 'Gleam', 'FreezeExplodeEjecta', 'groundFireBig', 'Flamethrower', 'Flamethrower2', 'FrostNova', 'Firebolt', 'Fireball', 'ChargedBolt', 'Icebolt', 'IceBlast', 'GlacialSpike', 'Blizzard', ] for (const key of MISSILE_CASING_KEYS) { expect(MISSILE_METAS[key], `MISSILE_METAS['${key}'] should resolve directly`).toBeDefined() expect(MISSILE_METAS[key.toLowerCase()], `MISSILE_METAS['${key.toLowerCase()}'] should resolve`).toBeDefined() } }) })