import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { parseMissilesTxt, getMissileTxtData, parseOverlayTxt, getOverlayTxtData, parseColorChannel } from '../src/game/skills.ts' import { numberCell, parseTable } from '../src/game/tables.ts' import { CANONICAL_LEVEL_LIGHTING, getLevelLighting } from '../src/game/levels-meta.ts' describe('P0 Data Contract & Table Repair Tests', () => { it('1. parseColorChannel correctly preserves authentic 0 values and falls back for empty', () => { expect(parseColorChannel('0')).toBe(0) expect(parseColorChannel(' 0 ')).toBe(0) expect(parseColorChannel('255')).toBe(255) expect(parseColorChannel('128')).toBe(128) expect(parseColorChannel('')).toBe(255) expect(parseColorChannel(' ')).toBe(255) expect(parseColorChannel(undefined)).toBe(255) expect(parseColorChannel(undefined, 100)).toBe(100) expect(parseColorChannel('invalid', 100)).toBe(100) }) it('2. numberCell in tables.ts preserves 0 while returning fallback on empty/whitespace cells', () => { const row = { zeroVal: '0', emptyVal: '', whitespaceVal: ' ', positiveVal: '128', } expect(numberCell(row, 'zeroVal', 255)).toBe(0) expect(numberCell(row, 'emptyVal', 255)).toBe(255) expect(numberCell(row, 'whitespaceVal', 255)).toBe(255) expect(numberCell(row, 'missingVal', 255)).toBe(255) expect(numberCell(row, 'positiveVal', 255)).toBe(128) }) it('3. authentic 1.13c Missiles with 0 color channels retain 0 instead of defaulting to 255', () => { const txtPath = join(process.cwd(), 'samples/d2/Patch_D2.mpq') // or txt // Test with actual table rows from 1.13c Missiles.txt const sampleTsv = [ 'Missile\tId\tVel\tMaxVel\tRange\tLevRange\tCelFile\tAnimLen\tAnimSpeed\tLoopAnim\tNumDirections\tExplosionMissile\tLight\tRed\tGreen\tBlue\tTrans', 'unholybolt1\t100\t20\t20\t50\t0\tUnholyBolt\t16\t16\t1\t1\t\t5\t0\t0\t0\t2', 'curseeffectred\t101\t20\t20\t50\t0\tCurse\t16\t16\t1\t1\t\t1\t255\t0\t0\t1', 'firearrow\t12\t24\t24\t40\t0\tFireArrow\t8\t16\t0\t32\tfireexplode\t6\t255\t178\t64\t1', 'arrow\t0\t24\t24\t40\t0\tArrow\t1\t16\t0\t32\t\t0\t\t\t\t0', ].join('\n') const map = parseMissilesTxt(sampleTsv) const unholy = map.get('unholybolt1')! expect(unholy).toBeDefined() expect(unholy.light).toBe(5) expect(unholy.red).toBe(0) expect(unholy.green).toBe(0) expect(unholy.blue).toBe(0) expect(unholy.trans).toBe(2) const curseRed = map.get('curseeffectred')! expect(curseRed).toBeDefined() expect(curseRed.light).toBe(1) expect(curseRed.red).toBe(255) expect(curseRed.green).toBe(0) expect(curseRed.blue).toBe(0) expect(curseRed.trans).toBe(1) const fireArrow = map.get('firearrow')! expect(fireArrow.trans).toBe(1) const arrow = map.get('arrow')! expect(arrow.trans).toBe(0) expect(arrow.red).toBe(255) // fallback on empty }) it('4. authentic 1.13c Overlay curse lines retain true 0 color channels', () => { const sampleTsv = [ 'overlay\tFilename\tFrames\tAnimRate\tTrans\tPreDraw\tInitRadius\tRadius\tRed\tGreen\tBlue', 'curseironmaiden\tironmaiden\t14\t16\t3\t0\t1\t6\t255\t0\t0', 'curseconfuse\tconfuse\t14\t16\t3\t0\t1\t6\t0\t255\t0', 'cursedecrepify\tdecrepify\t14\t16\t3\t0\t1\t6\t0\t0\t255', ].join('\n') const map = parseOverlayTxt(sampleTsv) const im = map.get('curseironmaiden')! expect(im.red).toBe(255) expect(im.green).toBe(0) expect(im.blue).toBe(0) const confuse = map.get('curseconfuse')! expect(confuse.red).toBe(0) expect(confuse.green).toBe(255) expect(confuse.blue).toBe(0) const decrepify = map.get('cursedecrepify')! expect(decrepify.red).toBe(0) expect(decrepify.green).toBe(0) expect(decrepify.blue).toBe(255) }) it('5. getMissileTxtData returns authentic trans values for physical vs magical missiles', () => { const arrow = getMissileTxtData('arrow') expect(arrow.trans).toBe(0) const multi = getMissileTxtData('multipleshotarrow') expect(multi.trans).toBe(0) const meteor = getMissileTxtData('meteor') expect(meteor.trans).toBe(0) const firebolt = getMissileTxtData('firebolt') expect(firebolt.trans).toBe(1) const chargedbolt = getMissileTxtData('chargedbolt') expect(chargedbolt.trans).toBe(1) const firearrow = getMissileTxtData('firearrow') expect(firearrow.trans).toBe(1) const magicarrow = getMissileTxtData('magicarrow') expect(magicarrow.trans).toBe(1) const icearrow = getMissileTxtData('icearrow') expect(icearrow.trans).toBe(1) const javelin = getMissileTxtData('lightningjavelin') expect(javelin.trans).toBe(1) }) it('6. CANONICAL_LEVEL_LIGHTING covers all 136 1.13c levels with authentic metadata', () => { expect(Object.keys(CANONICAL_LEVEL_LIGHTING).length).toBe(136) // Rogue Encampment (Act 1 Town, outdoor) const town1 = getLevelLighting(1)! expect(town1).toBeDefined() expect(town1.isInside).toBe(false) expect(town1.act).toBe(1) // Catacombs 4 (Andariel, inside) const cata4 = getLevelLighting(39)! // Catacombs Level 4 is id 37 or 39 // Level 37: Catacombs 4 const level37 = getLevelLighting(37)! expect(level37.isInside).toBe(true) expect(level37.intensity).toBe(0) // 0 intensity = pitch dark, authentic 1.13c ground truth // Lut Gholein Sewer 1 (Intensity = 8) const sewer1 = getLevelLighting(47)! expect(sewer1.isInside).toBe(true) expect(sewer1.intensity).toBe(8) // Harem (Intensity = 128) const harem = getLevelLighting(50)! expect(harem.isInside).toBe(true) expect(harem.intensity).toBe(128) // Arcane Sanctuary (NoPer = true) const arcane = getLevelLighting(74)! expect(arcane.noPer).toBe(true) // Duriel's Lair (NoPer = true, inside = true) const duriel = getLevelLighting(73)! expect(duriel.noPer).toBe(true) expect(duriel.isInside).toBe(true) }) })