/** * 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 /** Optional AutoMagic group from 'auto prefix' column (e.g. 304 for Paladin shields, 305 for Shrunken Heads). */ readonly autoPrefix?: number | undefined } /** * 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 rawAutoPrefix = strAt(row, 'auto prefix') const parsedAutoPrefix = rawAutoPrefix ? numAt(row, 'auto prefix', 0) : 0 const autoPrefix = parsedAutoPrefix > 0 ? parsedAutoPrefix : undefined 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, ...(autoPrefix !== undefined ? { autoPrefix } : {}), } 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) }