diff --git a/src/game/armor.ts b/src/game/armor.ts new file mode 100644 index 0000000..5a951e5 --- /dev/null +++ b/src/game/armor.ts @@ -0,0 +1,265 @@ +/** + * Diablo II armor base item loader (`Armor.txt`). + * + * Loads all 202 armor bases (helms, body armors, shields, gloves, boots, belts, etc.) + * from `data/global/excel/Armor.txt` in Diablo II MPQ archives. + * + * Each entry conforms to and extends {@link ItemBase}, providing qlvl, defense range (minac/maxac), + * strength requirements, durability, socket counts, normal/exceptional/elite tier links, + * and inventory dimensions. + */ +import type { D2Table } from './acts.ts' +import { parseTable } from './acts.ts' +import type { MountedArchives } from '../mpq/mount.ts' +import type { ItemBase } from './items.ts' + +/** Canonical MPQ path of `Armor.txt`. */ +export const ARMOR_TABLE_PATH = 'data\\global\\excel\\Armor.txt' + +/** + * An individual armor base item parsed from `Armor.txt`. + * Extends {@link ItemBase} to integrate seamlessly with the game item system. + */ +export interface ArmorBase extends ItemBase { + /** 3-character item code (e.g., 'cap', 'xtp', 'uap'). Matches {@link ItemBase.id}. */ + readonly id: string + /** 3-character item code. */ + readonly code: string + /** Internal development note / name from `name` column (e.g. 'Cap/hat'). */ + readonly name: string + /** Display name string key from `namestr` column (202/202 valid in string.tbl). */ + readonly namestr: string + /** 0 for classic items, 100 for expansion items. */ + readonly version: number + /** Broad kind identifier, always 'armor'. */ + readonly kind: 'armor' + /** Whether the item can drop or be spawned in normal gameplay (all 202 are true). */ + readonly spawnable: boolean + /** Auto-TC weight within quality tier bucket (1..4). */ + readonly rarity: number + /** Base quality level (qlvl, 1..85) used for bucket placement and drop reduction. */ + readonly level: number + /** Minimum character level required to equip. */ + readonly levelreq: number + /** Minimum base defense roll. */ + readonly minac: number + /** Maximum base defense roll. */ + readonly maxac: number + /** Strength requirement to equip. */ + readonly reqstr: number + /** Maximum durability (0 for indestructible items). */ + readonly durability: number + /** Base purchase cost in gold. */ + readonly cost: number + /** Gambling cost in gold. */ + readonly gambleCost: number + /** Primary item type code (e.g. 'helm', 'tors', 'shld', 'glov', 'boot', 'belt'). */ + readonly type: string + /** Optional secondary item type code (if present). */ + readonly type2?: string | undefined + /** Maximum number of gem/rune sockets. */ + readonly gemsockets: number + /** Gem application type modifier index. */ + readonly gemapplytype: number + /** Code of the normal tier version (e.g., 'cap'). */ + readonly normcode: string + /** Code of the exceptional tier version (e.g., 'xap'). */ + readonly ubercode: string + /** Code of the elite tier version (e.g., 'uap'). */ + readonly ultracode: string + /** Width in inventory grid cells. */ + readonly invWidth: number + /** Height in inventory grid cells. */ + readonly invHeight: number + /** Tags matching item types, containing [type] or [type, type2]. */ + readonly tags: string[] + /** Defense rating, representing maxac. */ + readonly defense: number + /** Maximum stack count (1 for all armors). */ + readonly maxStack: number + /** Item gold value, matching base cost. */ + readonly value: number + /** Inherent weapon attack damage (0 for armors). */ + readonly damage: number +} + +/** + * Case-insensitive, whitespace-tolerant Map for looking up armors 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 collection of all armor bases. + */ +export class ArmorTable { + /** All 202 armor bases in file order. */ + readonly all: ArmorBase[] + /** Map for looking up armors by 3-letter code (case-insensitive). */ + readonly byCode: Map + + constructor(all: ArmorBase[], byCode: Map) { + this.all = all + this.byCode = byCode + } + + /** + * Retrieves an armor base by its 3-letter code. + * Case-insensitive and trimmed. + */ + get(code: string): ArmorBase | undefined { + return this.byCode.get(code) + } +} + +/** + * Parses `Armor.txt` from a raw tab-separated {@link D2Table}. + * + * Filters out: + * - The `Expansion` separator row + * - Any rows with empty `code` + * + * In vanilla Diablo II 1.13c: 203 raw lines - 1 'Expansion' row = exactly 202 data rows. + * + * @param table - Raw parsed tab-separated table. + * @returns Parsed {@link ArmorTable}. + */ +export function parseArmorTable(table: D2Table): ArmorTable { + const colMap = new Map() + const lowerMap = new Map() + + table.header.forEach((name, idx) => { + // Only register the first occurrence of duplicate column names + // (e.g. mindam/maxdam appear at indices 63/64 and 161/162) + if (!colMap.has(name)) { + colMap.set(name, idx) + } + const lower = name.toLowerCase() + if (!lowerMap.has(lower)) { + lowerMap.set(lower, idx) + } + }) + + const strAt = (row: readonly string[], colName: string): string => { + const idx = colMap.get(colName) ?? lowerMap.get(colName.toLowerCase()) + return idx !== undefined ? (row[idx] ?? '').trim() : '' + } + + const numAt = (row: readonly string[], colName: string, fallback = 0): number => { + const raw = strAt(row, colName) + if (!raw) return fallback + const val = Number(raw) + return Number.isFinite(val) ? val : fallback + } + + const all: ArmorBase[] = [] + const byCode = new CaseInsensitiveMap() + + for (const row of table.rows) { + const code = strAt(row, 'code') + const name = strAt(row, 'name') + + // Filter out the 'Expansion' separator row and any empty code rows + if (!code || name === 'Expansion' || code === 'Expansion') { + continue + } + + const namestr = strAt(row, 'namestr') + const version = numAt(row, 'version', 0) + const spawnable = strAt(row, 'spawnable') === '1' + const rarity = numAt(row, 'rarity', 0) + const level = numAt(row, 'level', 0) + const levelreq = numAt(row, 'levelreq', 0) + const minac = numAt(row, 'minac', 0) + const maxac = numAt(row, 'maxac', 0) + const reqstr = numAt(row, 'reqstr', 0) + const durability = numAt(row, 'durability', 0) + const cost = numAt(row, 'cost', 0) + const gambleCost = numAt(row, 'gamble cost', 0) + const type = strAt(row, 'type') + const rawType2 = strAt(row, 'type2') + const type2 = rawType2.length > 0 ? rawType2 : undefined + const gemsockets = numAt(row, 'gemsockets', 0) + const gemapplytype = numAt(row, 'gemapplytype', 0) + const normcode = strAt(row, 'normcode') + const ubercode = strAt(row, 'ubercode') + const ultracode = strAt(row, 'ultracode') + const invWidth = numAt(row, 'invwidth', 0) + const invHeight = numAt(row, 'invheight', 0) + + const tags: string[] = [] + if (type) tags.push(type) + if (type2) tags.push(type2) + + const defense = maxac + + const armor: ArmorBase = { + id: code, + code, + name, + namestr, + version, + kind: 'armor', + spawnable, + rarity, + level, + levelreq, + minac, + maxac, + reqstr, + durability, + cost, + gambleCost, + type, + ...(type2 !== undefined ? { type2 } : {}), + gemsockets, + gemapplytype, + normcode, + ubercode, + ultracode, + invWidth, + invHeight, + tags, + defense, + maxStack: 1, + value: cost, + damage: 0, + } + + all.push(armor) + byCode.set(code, armor) + } + + return new ArmorTable(all, byCode) +} + +/** + * Loads and parses `Armor.txt` from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @returns Promise resolving to {@link ArmorTable}. + */ +export async function loadArmor(archives: MountedArchives): Promise { + const bytes = await archives.read(ARMOR_TABLE_PATH) + const d2Table = parseTable(bytes) + return parseArmorTable(d2Table) +} diff --git a/tests/armor.test.ts b/tests/armor.test.ts new file mode 100644 index 0000000..3551067 --- /dev/null +++ b/tests/armor.test.ts @@ -0,0 +1,303 @@ +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 { parseTable, cell } from '../src/game/acts.ts' +import type { ItemBase } from '../src/game/items.ts' +import { + ARMOR_TABLE_PATH, + ArmorTable, + parseArmorTable, + loadArmor, + type ArmorBase, +} from '../src/game/armor.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('Armor.txt loader (Issue #91)', () => { + let table: ArmorTable + 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(ARMOR_TABLE_PATH) + rawTable = parseTable(bytes) + table = parseArmorTable(rawTable) + } else { + rawTable = createMinimalFallbackTable() + table = parseArmorTable(rawTable) + } + }) + + it('correctly uses the canonical MPQ table path', () => { + expect(ARMOR_TABLE_PATH).toBe('data\\global\\excel\\Armor.txt') + }) + + it('loads armor table directly via loadArmor 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 loadArmor(archives) + expect(loaded).toBeInstanceOf(ArmorTable) + expect(loaded.all.length).toBe(202) + expect(loaded.byCode.size).toBe(202) + + const cap = loaded.get('cap') + expect(cap).toBeDefined() + expect(cap?.code).toBe('cap') + expect(cap?.level).toBe(1) + } + }) + + it('parses exactly 202 valid data rows from 203 raw rows (excluding Expansion row)', () => { + if (hasD2 && rawTable) { + // 203 raw lines in Armor.txt: 1 header + 203 data/separator rows + expect(rawTable.rows.length).toBe(203) + + // Find the Expansion separator row + const expansionRowIndex = rawTable.rows.findIndex( + r => cell(rawTable!, r, 'name').trim() === 'Expansion', + ) + expect(expansionRowIndex).toBeGreaterThanOrEqual(0) + expect(cell(rawTable, rawTable.rows[expansionRowIndex]!, 'code').trim()).toBe('') + } + + expect(table.all.length).toBe(202) + expect(table.byCode.size).toBe(202) + }) + + it('verifies 0 failures and 0 duplicate item codes', () => { + const seenCodes = new Set() + const duplicates: string[] = [] + + for (const armor of table.all) { + if (seenCodes.has(armor.code)) { + duplicates.push(armor.code) + } + seenCodes.add(armor.code) + + // Every armor row must have valid identifiers + expect(armor.id).toBeTruthy() + expect(armor.code).toBeTruthy() + expect(armor.name).toBeTruthy() + expect(armor.namestr).toBeTruthy() + expect(armor.type).toBeTruthy() + expect(armor.kind).toBe('armor') + expect(armor.spawnable).toBe(true) + } + + expect(duplicates).toEqual([]) + expect(seenCodes.size).toBe(table.all.length) + }) + + it('verifies rarity in 1..4 and level in 1..85 across all armor bases', () => { + let minRarity = Infinity + let maxRarity = -Infinity + let minLevel = Infinity + let maxLevel = -Infinity + + for (const armor of table.all) { + expect(armor.rarity).toBeGreaterThanOrEqual(1) + expect(armor.rarity).toBeLessThanOrEqual(4) + if (armor.rarity < minRarity) minRarity = armor.rarity + if (armor.rarity > maxRarity) maxRarity = armor.rarity + + expect(armor.level).toBeGreaterThanOrEqual(1) + expect(armor.level).toBeLessThanOrEqual(85) + if (armor.level < minLevel) minLevel = armor.level + if (armor.level > maxLevel) maxLevel = armor.level + } + + if (hasD2) { + expect(minRarity).toBe(1) + expect(maxRarity).toBe(4) + expect(minLevel).toBe(1) + expect(maxLevel).toBe(85) + } + }) + + it('verifies row 0 "cap" attributes match gold standard specifications', () => { + const cap = table.all[0]! + expect(cap).toBeDefined() + expect(cap.id).toBe('cap') + expect(cap.code).toBe('cap') + expect(cap.name).toBe('Cap/hat') + expect(cap.namestr).toBe('cap') + expect(cap.version).toBe(0) + expect(cap.kind).toBe('armor') + expect(cap.spawnable).toBe(true) + expect(cap.rarity).toBe(1) + expect(cap.level).toBe(1) + expect(cap.levelreq).toBe(0) + expect(cap.minac).toBe(3) + expect(cap.maxac).toBe(5) + expect(cap.defense).toBe(5) + expect(cap.reqstr).toBe(0) + expect(cap.durability).toBe(12) + expect(cap.cost).toBe(64) + expect(cap.gambleCost).toBe(3016) + expect(cap.type).toBe('helm') + expect(cap.gemsockets).toBe(2) + expect(cap.gemapplytype).toBe(1) + expect(cap.normcode).toBe('cap') + expect(cap.ubercode).toBe('xap') + expect(cap.ultracode).toBe('uap') + expect(cap.invWidth).toBe(2) + expect(cap.invHeight).toBe(2) + expect(cap.tags).toEqual(['helm']) + expect(cap.maxStack).toBe(1) + expect(cap.value).toBe(64) + expect(cap.damage).toBe(0) + }) + + it('supports case-insensitive and trimmed lookups via table.get and table.byCode.get', () => { + const cap = table.get('cap') + expect(cap).toBeDefined() + expect(table.get('CAP')).toBe(cap) + expect(table.get(' cap ')).toBe(cap) + expect(table.get('CaP')).toBe(cap) + + expect(table.byCode.get('cap')).toBe(cap) + expect(table.byCode.get('CAP')).toBe(cap) + expect(table.byCode.get(' cap ')).toBe(cap) + expect(table.byCode.has('cap')).toBe(true) + expect(table.byCode.has('CAP')).toBe(true) + + expect(table.get('unknown_code')).toBeUndefined() + expect(table.byCode.get('unknown_code')).toBeUndefined() + expect(table.byCode.has('unknown_code')).toBe(false) + }) + + it('correctly links normal, exceptional, and elite tiers (cap -> xap -> uap)', () => { + const cap = table.get('cap')! + const xap = table.get('xap')! + const uap = table.get('uap')! + + expect(cap).toBeDefined() + expect(xap).toBeDefined() + expect(uap).toBeDefined() + + // Normal tier: Cap (qlvl 1) + expect(cap.level).toBe(1) + expect(cap.normcode).toBe('cap') + expect(cap.ubercode).toBe('xap') + expect(cap.ultracode).toBe('uap') + + // Exceptional tier: War Hat (qlvl 34) + expect(xap.name).toBe('War Hat') + expect(xap.level).toBe(34) + expect(xap.normcode).toBe('cap') + expect(xap.ubercode).toBe('xap') + expect(xap.ultracode).toBe('uap') + expect(xap.minac).toBe(45) + expect(xap.maxac).toBe(53) + expect(xap.reqstr).toBe(20) + + // Elite tier: Shako (qlvl 58) + expect(uap.name).toBe('Shako') + expect(uap.level).toBe(58) + expect(uap.normcode).toBe('cap') + expect(uap.ubercode).toBe('xap') + expect(uap.ultracode).toBe('uap') + expect(uap.minac).toBe(98) + expect(uap.maxac).toBe(141) + expect(uap.reqstr).toBe(50) + }) + + it('conforms to ItemBase interface', () => { + const cap = table.get('cap')! + const itemBase: ItemBase = cap + + expect(itemBase.id).toBe('cap') + expect(itemBase.name).toBe('Cap/hat') + expect(itemBase.kind).toBe('armor') + expect(itemBase.invWidth).toBe(2) + expect(itemBase.invHeight).toBe(2) + expect(itemBase.maxStack).toBe(1) + expect(itemBase.value).toBe(64) + expect(itemBase.damage).toBe(0) + expect(itemBase.defense).toBe(5) + expect(itemBase.tags).toEqual(['helm']) + expect(itemBase.level).toBe(1) + }) +}) + +/** + * Creates a minimal fallback table for test environments lacking the full MPQ archives. + */ +function createMinimalFallbackTable(): D2Table { + const header = [ + 'name', 'version', 'compactsave', 'rarity', 'spawnable', 'minac', 'maxac', + 'absorbs', 'speed', 'reqstr', 'block', 'durability', 'nodurability', 'level', + 'levelreq', 'cost', 'gamble cost', 'code', 'namestr', 'magic lvl', 'auto prefix', + 'alternategfx', 'OpenBetaGfx', 'normcode', 'ubercode', 'ultracode', 'spelloffset', + 'component', 'invwidth', 'invheight', 'hasinv', 'gemsockets', 'gemapplytype', + 'flippyfile', 'invfile', 'uniqueinvfile', 'setinvfile', 'rArm', 'lArm', 'Torso', + 'Legs', 'rSPad', 'lSPad', 'useable', 'throwable', 'stackable', 'minstack', + 'maxstack', 'type', 'type2', 'dropsound', 'dropsfxframe', 'usesound', 'unique', + 'transparent', 'transtbl', 'quivered', 'lightradius', 'belt', 'quest', 'missiletype', + 'durwarning', 'qntwarning', 'mindam', 'maxdam', 'StrBonus', 'DexBonus', 'gemoffset', + 'bitfield1', 'CharsiMin', 'CharsiMax', 'CharsiMagicMin', 'CharsiMagicMax', 'CharsiMagicLvl', + 'GheedMin', 'GheedMax', 'GheedMagicMin', 'GheedMagicMax', 'GheedMagicLvl', + 'AkaraMin', 'AkaraMax', 'AkaraMagicMin', 'AkaraMagicMax', 'AkaraMagicLvl', + 'FaraMin', 'FaraMax', 'FaraMagicMin', 'FaraMagicMax', 'FaraMagicLvl', + 'LysanderMin', 'LysanderMax', 'LysanderMagicMin', 'LysanderMagicMax', 'LysanderMagicLvl', + 'DrognanMin', 'DrognanMax', 'DrognanMagicMin', 'DrognanMagicMax', 'DrognanMagicLvl', + 'HraltiMin', 'HraltiMax', 'HraltiMagicMin', 'HraltiMagicMax', 'HratliMagicLvl', + 'AlkorMin', 'AlkorMax', 'AlkorMagicMin', 'AlkorMagicMax', 'AlkorMagicLvl', + 'OrmusMin', 'OrmusMax', 'OrmusMagicMin', 'OrmusMagicMax', 'OrmusMagicLvl', + 'ElzixMin', 'ElzixMax', 'ElzixMagicMin', 'ElzixMagicMax', 'ElzixMagicLvl', + 'AshearaMin', 'AshearaMax', 'AshearaMagicMin', 'AshearaMagicMax', 'AshearaMagicLvl', + 'CainMin', 'CainMax', 'CainMagicMin', 'CainMagicMax', 'CainMagicLvl', + 'HalbuMin', 'HalbuMax', 'HalbuMagicMin', 'HalbuMagicMax', 'HalbuMagicLvl', + 'JamellaMin', 'JamellaMax', 'JamellaMagicMin', 'JamellaMagicMax', 'JamellaMagicLvl', + 'LarzukMin', 'LarzukMax', 'LarzukMagicMin', 'LarzukMagicMax', 'LarzukMagicLvl', + 'MalahMin', 'MalahMax', 'MalahMagicMin', 'MalahMagicMax', 'MalahMagicLvl', + 'DrehyaMin', 'DrehyaMax', 'DrehyaMagicMin', 'DrehyaMagicMax', 'DrehyaMagicLvl', + 'Source Art', 'Game Art', 'Transform', 'InvTrans', 'SkipName', 'NightmareUpgrade', + 'HellUpgrade', 'mindam', 'maxdam', 'nameable', + ] + + const makeRow = (name: string, code: string, level: string, minac: string, maxac: string, reqstr: string, durability: string, sockets: string, norm: string, uber: string, ultra: string, cost: string, gamble: string) => { + const row = new Array(header.length).fill('') + row[header.indexOf('name')] = name + row[header.indexOf('code')] = code + row[header.indexOf('namestr')] = code + row[header.indexOf('version')] = '0' + row[header.indexOf('spawnable')] = '1' + row[header.indexOf('rarity')] = '1' + row[header.indexOf('level')] = level + row[header.indexOf('levelreq')] = '0' + row[header.indexOf('minac')] = minac + row[header.indexOf('maxac')] = maxac + row[header.indexOf('reqstr')] = reqstr + row[header.indexOf('durability')] = durability + row[header.indexOf('cost')] = cost + row[header.indexOf('gamble cost')] = gamble + row[header.indexOf('type')] = 'helm' + row[header.indexOf('gemsockets')] = sockets + row[header.indexOf('gemapplytype')] = '1' + row[header.indexOf('normcode')] = norm + row[header.indexOf('ubercode')] = uber + row[header.indexOf('ultracode')] = ultra + row[header.indexOf('invwidth')] = '2' + row[header.indexOf('invheight')] = '2' + return row + } + + const rows: string[][] = [ + makeRow('Cap/hat', 'cap', '1', '3', '5', '0', '12', '2', 'cap', 'xap', 'uap', '64', '3016'), + makeRow('War Hat', 'xap', '34', '45', '53', '20', '12', '2', 'cap', 'xap', 'uap', '500', '10000'), + makeRow('Shako', 'uap', '58', '98', '141', '50', '12', '2', 'cap', 'xap', 'uap', '2000', '50000'), + ] + + return { header, rows } +}