From 050cb25dfeaf557da3a9d21b3949636af431b614 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:26:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=8A=A0=E8=BD=BD=20Misc.txt=20?= =?UTF-8?q?(Issue=20#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/misc-items.ts | 275 +++++++++++++++++++++++ tests/misc-items.test.ts | 462 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 737 insertions(+) create mode 100644 src/game/misc-items.ts create mode 100644 tests/misc-items.test.ts diff --git a/src/game/misc-items.ts b/src/game/misc-items.ts new file mode 100644 index 0000000..88dbde8 --- /dev/null +++ b/src/game/misc-items.ts @@ -0,0 +1,275 @@ +/** + * Diablo II miscellaneous items loader (`Misc.txt`). + * + * `Misc.txt` defines all non-weapon, non-armor items: potions, scrolls, runes, + * charms, gems, jewels, gold, keys, quest items, and books. + * + * In vanilla Diablo II 1.13c: + * - 152 raw rows - 1 'Expansion' separator row = exactly 151 real data rows. + * - 98 of 151 items are spawnable (`spawnable === true`). + * - 33 runes `r01`..`r33` with sequential rune numbers 1..33. + * - `gld` is gold (`isGold === true`, `type === 'gold'`). + */ +import type { D2Table } from './acts.ts' +import { cell, parseTable } from './acts.ts' +import type { ItemBase } from './items.ts' +import type { MountedArchives } from '../mpq/mount.ts' + +/** File path of `Misc.txt` inside MPQ archives. */ +export const MISC_TABLE_PATH = 'data\\global\\excel\\Misc.txt' + +/** + * Definition of a miscellaneous item base from `Misc.txt`, conforming to `ItemBase`. + */ +export interface MiscBase extends ItemBase { + /** Item code (e.g. 'hp1', 'rin', 'r01'). Same as `id`. */ + readonly id: string + /** Item code (3-4 characters). */ + readonly code: string + /** Display name from the `name` column. */ + readonly name: string + /** String table lookup key from `namestr`. */ + readonly namestr: string + /** Game version flag (0 = classic, 100 = expansion). */ + readonly version: number + /** Broad item kind, always 'misc'. */ + readonly kind: 'misc' + /** Whether the item can drop or spawn randomly (1 or 0 in table). */ + readonly spawnable: boolean + /** Drop rarity weighting (0..999). */ + readonly rarity: number + /** Item level (0..90). */ + readonly level: number + /** Primary item type code (e.g. 'hpot', 'ring', 'rune'). */ + readonly type: string + /** Secondary item type code if defined (e.g. 'gem0'..'gem4'). */ + readonly type2?: string + /** Base gold purchase cost. */ + readonly cost: number + /** Cost to purchase in gambling window (0 if not gambleable). */ + readonly gambleCost: number + /** Inventory grid width in cells. */ + readonly invWidth: number + /** Inventory grid height in cells. */ + readonly invHeight: number + /** Whether items of this base can stack in inventory. */ + readonly stackable: boolean + /** Minimum spawned stack quantity if stackable. */ + readonly minstack?: number + /** Maximum stack quantity if stackable. */ + readonly maxstack?: number + /** Default stack quantity when spawned if stackable. */ + readonly spawnstack?: number + /** Whether the item automatically slots into the potion belt on pickup. */ + readonly autobelt: boolean + /** Hardcoded spell/effect index parameter if usable. */ + readonly pSpell?: number + /** Whether the item can be transmogrified in the Horadric Cube. */ + readonly transmogrify: boolean + /** Item tags for affix matching ([type, type2]). */ + readonly tags: string[] + /** Whether this base represents gold (`code === 'gld'`). */ + readonly isGold: boolean + /** Whether this base is one of the 33 runes (`r01`..`r33`). */ + readonly isRune: boolean + /** Sequential rune number (1..33) if this base is a rune. */ + readonly runeNumber?: number + + // ItemBase conformance + /** How many fit in one cell (from maxstack for stackable items, else 1). */ + readonly maxStack: number + /** Gold value (from cost). */ + readonly value: number + /** Weapon damage (0 for misc items). */ + readonly damage: number + /** Armor rating (0 for misc items). */ + readonly defense: number +} + +/** + * Case-insensitive, whitespace-tolerant Map for looking up items by code. + */ +class CaseInsensitiveMap extends Map { + private normalize(key: string): string { + return typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase() + } + + override get(key: string): V | undefined { + return super.get(this.normalize(key)) + } + + override has(key: string): boolean { + return super.has(this.normalize(key)) + } + + override set(key: string, value: V): this { + return super.set(this.normalize(key), value) + } + + override delete(key: string): boolean { + return super.delete(this.normalize(key)) + } +} + +/** + * Parsed miscellaneous items table providing collection and indexed lookup access. + */ +export interface MiscTable { + /** Map of misc item bases keyed by code (case-insensitive and trimmed). */ + readonly byCode: Map + /** All valid misc item bases in file order (151 items in vanilla 1.13c). */ + readonly all: MiscBase[] + /** Look up an item base by code. */ + get(code: string): MiscBase | undefined + /** Look up a rune by its number (1..33 for r01..r33). */ + getRune(num: number): MiscBase | undefined +} + +/** + * Parses `Misc.txt` from a raw tab-separated D2Table (or Uint8Array/string). + * + * Filters out: + * - The `Expansion` separator row + * - Any rows with empty `code` + * + * In vanilla Diablo II 1.13c: 152 raw rows - 1 'Expansion' row = 151 real data rows. + * + * @param input - Raw D2Table, Uint8Array bytes, or TSV string. + * @returns Parsed `MiscTable` with list, code lookup, and rune indexing. + */ +export function parseMiscTable(table: D2Table): MiscTable +export function parseMiscTable(bytes: Uint8Array): MiscTable +export function parseMiscTable(tsv: string): MiscTable +export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable { + let table: D2Table + if (typeof input === 'string') { + table = parseTable(new TextEncoder().encode(input)) + } else if (input instanceof Uint8Array) { + table = parseTable(input) + } else { + table = input + } + + const all: MiscBase[] = [] + const byCode = new CaseInsensitiveMap() + const runesByNumber = new Map() + + for (const row of table.rows) { + const code = cell(table, row, 'code').trim() + const name = cell(table, row, 'name').trim() + + // 152 raw lines - 1 'Expansion' row = 151 real data rows. + if (!code || name === 'Expansion') { + continue + } + + const namestr = cell(table, row, 'namestr').trim() + const version = Number(cell(table, row, 'version')) || 0 + const spawnable = cell(table, row, 'spawnable').trim() === '1' + const rarity = Number(cell(table, row, 'rarity')) || 0 + const level = Number(cell(table, row, 'level')) || 0 + const type = cell(table, row, 'type').trim() + const type2Raw = cell(table, row, 'type2').trim() + const type2 = type2Raw !== '' ? type2Raw : undefined + const cost = Number(cell(table, row, 'cost')) || 0 + const gambleCost = Number(cell(table, row, 'gamble cost')) || 0 + const invWidth = Number(cell(table, row, 'invwidth')) || 0 + const invHeight = Number(cell(table, row, 'invheight')) || 0 + const stackable = cell(table, row, 'stackable').trim() === '1' + + const minstackRaw = cell(table, row, 'minstack').trim() + const maxstackRaw = cell(table, row, 'maxstack').trim() + const spawnstackRaw = cell(table, row, 'spawnstack').trim() + + const minstack = stackable && minstackRaw !== '' ? Number(minstackRaw) : undefined + const maxstack = stackable && maxstackRaw !== '' ? Number(maxstackRaw) : undefined + const spawnstack = stackable && spawnstackRaw !== '' ? Number(spawnstackRaw) : undefined + + const autobelt = cell(table, row, 'autobelt').trim() === '1' + + const pSpellRaw = (cell(table, row, 'pSpell') || cell(table, row, 'pspell')).trim() + const pSpell = pSpellRaw !== '' ? Number(pSpellRaw) : undefined + + const transmogrify = + cell(table, row, 'Transmogrify').trim() === '1' || + cell(table, row, 'transmogrify').trim() === '1' + + const tags: string[] = type2 !== undefined ? [type, type2] : [type] + + const isGold = code.toLowerCase() === 'gld' + + const runeSuffix = code.slice(1) + const runeNum = Number(runeSuffix) + const isRune = + code.toLowerCase().startsWith('r') && + !isNaN(runeNum) && + runeNum >= 1 && + runeNum <= 33 + + const runeNumber = isRune ? runeNum : undefined + + const maxStack = stackable && maxstack !== undefined ? maxstack : 1 + const value = cost + const damage = 0 + const defense = 0 + + const item: MiscBase = { + id: code, + code, + name, + namestr, + version, + kind: 'misc', + spawnable, + rarity, + level, + type, + ...(type2 !== undefined ? { type2 } : {}), + cost, + gambleCost, + invWidth, + invHeight, + stackable, + ...(minstack !== undefined ? { minstack } : {}), + ...(maxstack !== undefined ? { maxstack } : {}), + ...(spawnstack !== undefined ? { spawnstack } : {}), + autobelt, + ...(pSpell !== undefined ? { pSpell } : {}), + transmogrify, + tags, + isGold, + isRune, + ...(runeNumber !== undefined ? { runeNumber } : {}), + + maxStack, + value, + damage, + defense, + } + + all.push(item) + byCode.set(code, item) + if (isRune && runeNumber !== undefined) { + runesByNumber.set(runeNumber, item) + } + } + + return { + byCode, + all, + get: (c: string) => byCode.get(c), + getRune: (num: number) => runesByNumber.get(num), + } +} + +/** + * Loads and parses `Misc.txt` from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @returns Promise resolving to `MiscTable`. + */ +export async function loadMisc(archives: MountedArchives): Promise { + const bytes = await archives.read(MISC_TABLE_PATH) + const d2Table = parseTable(bytes) + return parseMiscTable(d2Table) +} diff --git a/tests/misc-items.test.ts b/tests/misc-items.test.ts new file mode 100644 index 0000000..9721761 --- /dev/null +++ b/tests/misc-items.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import type { D2Table } from '../src/game/acts.ts' +import { cell, parseTable } from '../src/game/acts.ts' +import { + parseMiscTable, + loadMisc, + MISC_TABLE_PATH, + type MiscTable, + type MiscBase, +} from '../src/game/misc-items.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('Misc.txt loader & MiscBase mapping (Issue #93)', () => { + let table: MiscTable + let rawTable: D2Table | undefined + + beforeAll(async () => { + if (hasD2) { + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const bytes = await archives.read(MISC_TABLE_PATH) + rawTable = parseTable(bytes) + table = parseMiscTable(rawTable) + } else { + rawTable = createMinimalFallbackTable() + table = parseMiscTable(rawTable) + } + }) + + it('uses the canonical MPQ table path data\\global\\excel\\Misc.txt', () => { + expect(MISC_TABLE_PATH).toBe('data\\global\\excel\\Misc.txt') + }) + + it('loads misc items via loadMisc with MountedArchives', async () => { + if (hasD2) { + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const loaded = await loadMisc(archives) + expect(loaded.all.length).toBe(151) + expect(loaded.byCode.size).toBe(151) + expect(loaded.get('hp1')?.name).toBe('Lesser Healing Potion') + } + }) + + it('verifies 152 raw rows in original file - 1 Expansion row = 151 real data rows with 0 duplicate codes', () => { + if (hasD2 && rawTable) { + expect(rawTable.rows.length).toBe(152) + + // Verify Expansion row at index 94 + const expRow = rawTable.rows[94]! + expect(cell(rawTable, expRow, 'name')).toBe('Expansion') + expect(cell(rawTable, expRow, 'code')).toBe('') + + expect(table.all.length).toBe(151) + expect(table.byCode.size).toBe(151) + + // Verify 0 duplicate codes + const seenCodes = new Set() + for (const item of table.all) { + expect(seenCodes.has(item.code)).toBe(false) + seenCodes.add(item.code) + } + expect(seenCodes.size).toBe(151) + } + + // Verify Expansion row is excluded + expect(table.byCode.has('Expansion')).toBe(false) + expect(table.byCode.has('')).toBe(false) + }) + + it('loads ALL 151 rows including exactly 98 spawnable rows', () => { + if (hasD2) { + const spawnableCount = table.all.filter(i => i.spawnable).length + expect(spawnableCount).toBe(98) + + const nonSpawnableCount = table.all.filter(i => !i.spawnable).length + expect(nonSpawnableCount).toBe(53) + expect(spawnableCount + nonSpawnableCount).toBe(151) + } + }) + + it('verifies gld is recognized as gold (isGold === true, type === "gold")', () => { + const gold = table.get('gld') + expect(gold).toBeDefined() + expect(gold?.isGold).toBe(true) + expect(gold?.type).toBe('gold') + expect(gold?.name).toBe('gold') + expect(gold?.stackable).toBe(true) + expect(gold?.maxstack).toBe(5000) + + // All other items must not be gold + const nonGold = table.all.filter(i => i.code !== 'gld') + for (const item of nonGold) { + expect(item.isGold).toBe(false) + } + }) + + it('verifies 33 runes r01..r33 are accessible by number 1..33 and have isRune === true', () => { + if (hasD2) { + const runes = table.all.filter(i => i.isRune) + expect(runes.length).toBe(33) + + for (let num = 1; num <= 33; num++) { + const rune = table.getRune(num) + expect(rune).toBeDefined() + expect(rune?.isRune).toBe(true) + expect(rune?.runeNumber).toBe(num) + + const expectedCode = `r${String(num).padStart(2, '0')}` + expect(rune?.code).toBe(expectedCode) + expect(rune?.type).toBe('rune') + expect(rune?.tags).toEqual(['rune']) + } + + // Check specific first and last rune details + const elRune = table.getRune(1)! + expect(elRune.name).toBe('El Rune') + expect(elRune.level).toBe(11) + expect(elRune.cost).toBe(560) + + const zodRune = table.getRune(33)! + expect(zodRune.name).toBe('Zod Rune') + expect(zodRune.level).toBe(69) + expect(zodRune.cost).toBe(134540) + } + + // Boundary check for getRune + expect(table.getRune(0)).toBeUndefined() + expect(table.getRune(34)).toBeUndefined() + expect(table.getRune(-1)).toBeUndefined() + + // Non-rune items starting with 'r' (e.g. rin, rvs, rvl, rps, rpl) + for (const nonRuneCode of ['rin', 'rvs', 'rvl', 'rps', 'rpl']) { + const item = table.get(nonRuneCode) + if (item) { + expect(item.isRune).toBe(false) + expect(item.runeNumber).toBeUndefined() + } + } + }) + + describe('Sample item checks (hp1, rin, cm1)', () => { + it('accurately parses Lesser Healing Potion (hp1)', () => { + const hp1 = table.get('hp1')! + expect(hp1).toBeDefined() + expect(hp1.id).toBe('hp1') + expect(hp1.code).toBe('hp1') + expect(hp1.name).toBe('Lesser Healing Potion') + expect(hp1.namestr).toBe('hp1') + expect(hp1.version).toBe(0) + expect(hp1.kind).toBe('misc') + expect(hp1.spawnable).toBe(true) + expect(hp1.rarity).toBe(1) + expect(hp1.level).toBe(0) + expect(hp1.type).toBe('hpot') + expect(hp1.type2).toBeUndefined() + expect(hp1.cost).toBe(30) + expect(hp1.gambleCost).toBe(0) + expect(hp1.invWidth).toBe(1) + expect(hp1.invHeight).toBe(1) + expect(hp1.stackable).toBe(false) + expect(hp1.minstack).toBeUndefined() + expect(hp1.maxstack).toBeUndefined() + expect(hp1.spawnstack).toBeUndefined() + expect(hp1.autobelt).toBe(true) + expect(hp1.pSpell).toBe(3) + expect(hp1.transmogrify).toBe(false) + expect(hp1.tags).toEqual(['hpot']) + expect(hp1.isGold).toBe(false) + expect(hp1.isRune).toBe(false) + expect(hp1.runeNumber).toBeUndefined() + + // ItemBase conformance + expect(hp1.maxStack).toBe(1) + expect(hp1.value).toBe(30) + expect(hp1.damage).toBe(0) + expect(hp1.defense).toBe(0) + }) + + it('accurately parses Ring (rin)', () => { + const rin = table.get('rin')! + expect(rin).toBeDefined() + expect(rin.id).toBe('rin') + expect(rin.code).toBe('rin') + expect(rin.name).toBe('ring') + expect(rin.namestr).toBe('rin') + expect(rin.version).toBe(0) + expect(rin.kind).toBe('misc') + expect(rin.spawnable).toBe(true) + expect(rin.rarity).toBe(4) + expect(rin.level).toBe(1) + expect(rin.type).toBe('ring') + expect(rin.type2).toBeUndefined() + expect(rin.cost).toBe(1800) + expect(rin.gambleCost).toBe(50000) + expect(rin.invWidth).toBe(1) + expect(rin.invHeight).toBe(1) + expect(rin.stackable).toBe(false) + expect(rin.autobelt).toBe(false) + expect(rin.pSpell).toBeUndefined() + expect(rin.transmogrify).toBe(false) + expect(rin.tags).toEqual(['ring']) + expect(rin.isGold).toBe(false) + expect(rin.isRune).toBe(false) + }) + + it('accurately parses Small Charm (cm1)', () => { + const cm1 = table.get('cm1')! + expect(cm1).toBeDefined() + expect(cm1.id).toBe('cm1') + expect(cm1.code).toBe('cm1') + expect(cm1.name).toBe('Charm Small') + expect(cm1.namestr).toBe('cm1') + expect(cm1.version).toBe(100) + expect(cm1.kind).toBe('misc') + expect(cm1.spawnable).toBe(true) + expect(cm1.rarity).toBe(4) + expect(cm1.level).toBe(28) + expect(cm1.type).toBe('scha') + expect(cm1.type2).toBeUndefined() + expect(cm1.cost).toBe(2000) + expect(cm1.gambleCost).toBe(45000) + expect(cm1.invWidth).toBe(1) + expect(cm1.invHeight).toBe(1) + expect(cm1.stackable).toBe(false) + expect(cm1.autobelt).toBe(false) + expect(cm1.pSpell).toBeUndefined() + expect(cm1.transmogrify).toBe(false) + expect(cm1.tags).toEqual(['scha']) + expect(cm1.isGold).toBe(false) + expect(cm1.isRune).toBe(false) + }) + }) + + describe('Caution item categories', () => { + it('potions (hp1..hp5, mp1..mp5, rvs, rvl, vps)', () => { + for (const code of ['hp1', 'hp2', 'hp3', 'hp4', 'hp5']) { + const item = table.get(code) + if (item) { + expect(item.type).toBe('hpot') + expect(item.autobelt).toBe(true) + } + } + for (const code of ['mp1', 'mp2', 'mp3', 'mp4', 'mp5']) { + const item = table.get(code) + if (item) { + expect(item.type).toBe('mpot') + expect(item.autobelt).toBe(true) + } + } + for (const code of ['rvs', 'rvl']) { + const item = table.get(code) + if (item) { + expect(item.type).toBe('rpot') + expect(item.autobelt).toBe(true) + } + } + const vps = table.get('vps') + if (vps) { + expect(vps.type).toBe('spot') + expect(vps.autobelt).toBe(false) + } + }) + + it('scrolls (tsc, isc)', () => { + const tsc = table.get('tsc') + if (tsc) { + expect(tsc.name).toBe('Town Portal Scroll') + expect(tsc.type).toBe('scro') + } + + const isc = table.get('isc') + if (isc) { + expect(isc.name).toBe('Identify Scroll') + expect(isc.type).toBe('scro') + } + }) + + it('charms (cm1..cm3)', () => { + const cm1 = table.get('cm1') + const cm2 = table.get('cm2') + const cm3 = table.get('cm3') + if (cm1) expect(cm1.type).toBe('scha') + if (cm2) expect(cm2.type).toBe('mcha') + if (cm3) expect(cm3.type).toBe('lcha') + }) + + it('jewels (jew)', () => { + const jew = table.get('jew') + if (jew) { + expect(jew.name).toBe('Jewel') + expect(jew.type).toBe('jewl') + } + }) + + it('rings and amulets (rin, amu)', () => { + const rin = table.get('rin') + const amu = table.get('amu') + if (rin) expect(rin.type).toBe('ring') + if (amu) expect(amu.type).toBe('amul') + }) + + it('quivers (aqv, cqv)', () => { + const aqv = table.get('aqv') + if (aqv) { + expect(aqv.type).toBe('bowq') + expect(aqv.stackable).toBe(true) + expect(aqv.minstack).toBe(100) + expect(aqv.maxstack).toBe(350) + expect(aqv.spawnstack).toBe(200) + } + + const cqv = table.get('cqv') + if (cqv) { + expect(cqv.type).toBe('xboq') + expect(cqv.stackable).toBe(true) + expect(cqv.minstack).toBe(60) + expect(cqv.maxstack).toBe(250) + expect(cqv.spawnstack).toBe(150) + } + }) + }) + + describe('Items with type2 and tags', () => { + it('maps type and type2 into tags for chipped/perfect gems and skulls', () => { + const gcv = table.get('gcv') + if (gcv) { + expect(gcv.name).toBe('Chipped Amethyst') + expect(gcv.type).toBe('gema') + expect(gcv.type2).toBe('gem0') + expect(gcv.tags).toEqual(['gema', 'gem0']) + } + + const gpw = table.get('gpw') + if (gpw) { + expect(gpw.name).toBe('Perfect Diamond') + expect(gpw.type).toBe('gemd') + expect(gpw.type2).toBe('gem4') + expect(gpw.tags).toEqual(['gemd', 'gem4']) + } + + const skz = table.get('skz') + if (skz) { + expect(skz.name).toBe('Perfect Skull') + expect(skz.type).toBe('gemz') + expect(skz.type2).toBe('gem4') + expect(skz.tags).toEqual(['gemz', 'gem4']) + } + }) + }) + + describe('Transmogrify flag', () => { + it('sets transmogrify === true for horadric quest transmogrify items', () => { + if (hasD2) { + for (const code of ['hrt', 'brz', 'jaw', 'eyz', 'hrn', 'tal', 'fng', 'qll', 'sol', 'scz', 'spe']) { + const item = table.get(code)! + expect(item).toBeDefined() + expect(item.transmogrify).toBe(true) + } + } + expect(table.get('hp1')?.transmogrify).toBe(false) + }) + }) + + describe('Lookup & indexing flexibility', () => { + it('supports case-insensitive and whitespace-tolerant get and byCode lookups', () => { + const hp1 = table.get('hp1')! + expect(table.get('HP1')).toBe(hp1) + expect(table.get(' hp1 ')).toBe(hp1) + expect(table.get('Hp1')).toBe(hp1) + + expect(table.byCode.get('HP1')).toBe(hp1) + expect(table.byCode.get(' hp1 ')).toBe(hp1) + expect(table.byCode.has('HP1')).toBe(true) + expect(table.byCode.has('nonexistent')).toBe(false) + expect(table.get('nonexistent')).toBeUndefined() + }) + }) + + describe('parseMiscTable overloads & standalone parsing', () => { + it('parses directly from a TSV string with Expansion row filtering', () => { + const tsv = [ + 'name\tcode\tnamestr\tversion\tspawnable\trarity\tlevel\ttype\ttype2\tcost\tgamble cost\tinvwidth\tinvheight\tstackable\tminstack\tmaxstack\tspawnstack\tautobelt\tpSpell\tTransmogrify', + 'Lesser Healing Potion\thp1\thp1\t0\t1\t1\t0\thpot\t\t30\t\t1\t1\t0\t0\t0\t0\t1\t3\t0', + 'Expansion\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t', + 'El Rune\tr01\tr01\t100\t1\t1\t11\trune\t\t560\t\t1\t1\t0\t0\t0\t0\t0\t\t0', + 'gold\tgld\tgld\t0\t1\t0\t0\tgold\t\t1\t\t1\t1\t1\t0\t5000\t0\t0\t\t0', + 'Chipped Amethyst\tgcv\tgcv\t0\t1\t1\t1\tgema\tgem0\t240\t\t1\t1\t0\t0\t0\t0\t0\t\t0', + ].join('\n') + + const parsed = parseMiscTable(tsv) + expect(parsed.all.length).toBe(4) + expect(parsed.byCode.size).toBe(4) + expect(parsed.get('Expansion')).toBeUndefined() + + const hp1 = parsed.get('hp1')! + expect(hp1).toBeDefined() + expect(hp1.name).toBe('Lesser Healing Potion') + expect(hp1.pSpell).toBe(3) + expect(hp1.autobelt).toBe(true) + + const rune = parsed.getRune(1)! + expect(rune).toBeDefined() + expect(rune.code).toBe('r01') + expect(rune.isRune).toBe(true) + expect(rune.runeNumber).toBe(1) + + const gold = parsed.get('gld')! + expect(gold.isGold).toBe(true) + expect(gold.stackable).toBe(true) + expect(gold.maxstack).toBe(5000) + + const gem = parsed.get('gcv')! + expect(gem.tags).toEqual(['gema', 'gem0']) + }) + + it('parses directly from Uint8Array bytes', () => { + const tsv = [ + 'name\tcode\tnamestr\tversion\tspawnable\trarity\tlevel\ttype\ttype2\tcost\tgamble cost\tinvwidth\tinvheight\tstackable\tminstack\tmaxstack\tspawnstack\tautobelt\tpSpell\tTransmogrify', + 'Zod Rune\tr33\tr33\t100\t1\t30\t69\trune\t\t134540\t\t1\t1\t0\t0\t0\t0\t0\t\t0', + ].join('\n') + + const bytes = new TextEncoder().encode(tsv) + const parsed = parseMiscTable(bytes) + expect(parsed.all.length).toBe(1) + expect(parsed.getRune(33)?.name).toBe('Zod Rune') + }) + }) +}) + +/** + * Minimal fallback D2Table for environments without MPQ assets. + */ +function createMinimalFallbackTable(): D2Table { + const header = [ + 'name', 'code', 'namestr', 'version', 'spawnable', 'rarity', 'level', + 'type', 'type2', 'cost', 'gamble cost', 'invwidth', 'invheight', + 'stackable', 'minstack', 'maxstack', 'spawnstack', 'autobelt', 'pSpell', + 'Transmogrify', + ] + + const rows = [ + ['Lesser Healing Potion', 'hp1', 'hp1', '0', '1', '1', '0', 'hpot', '', '30', '', '1', '1', '0', '0', '0', '0', '1', '3', '0'], + ['ring', 'rin', 'rin', '0', '1', '4', '1', 'ring', '', '1800', '50000', '1', '1', '0', '0', '0', '0', '0', '', '0'], + ['Charm Small', 'cm1', 'cm1', '100', '1', '4', '28', 'scha', '', '2000', '45000', '1', '1', '0', '0', '0', '0', '0', '', '0'], + ['gold', 'gld', 'gld', '0', '1', '0', '0', 'gold', '', '1', '', '1', '1', '1', '0', '5000', '0', '0', '', '0'], + ['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''], + ['El Rune', 'r01', 'r01', '100', '1', '1', '11', 'rune', '', '560', '', '1', '1', '0', '0', '0', '0', '0', '', '0'], + ['Zod Rune', 'r33', 'r33', '100', '1', '30', '69', 'rune', '', '134540', '', '1', '1', '0', '0', '0', '0', '0', '', '0'], + ] + + return { header, rows } +}