/** * Diablo II unique items loader (`UniqueItems.txt`). * * Gold standard reference: 1.13c MPQ `data/global/excel/UniqueItems.txt`. * * In Diablo II, unique items represent specific, named items with fixed * affixes and randomized ranges. Each item is tied to a base item (`code`) * and carries a quality level (`lvl`) and character level requirement (`levelreq`). * * Key data properties: * - 403 raw lines in 1.13c; minus the empty index row and 'Expansion' section * separator row = exactly 401 real unique item records. * - Exactly 385 items are enabled (`enabled === 1`). 16 items have `enabled === 0` * or blank, and are flagged as `enabled = false` so they do not enter the * runtime generation pool (e.g. classic Azurewrath, Constricting Ring, Gore Ripper). * - Multi-value index: multiple unique items can share the same base item code * (e.g., rings `rin` have 10 uniques in 1.13c, 9 enabled; amulets `amu` have 11 uniques). * - Up to 12 properties per item (`prop1..12`, `par1..12`, `min1..12`, `max1..12`). * Parameters (`par`) can be numeric (skill IDs) or skill name strings (e.g. "Oak Sage"). */ import type { D2Table } from './acts.ts' import { parseTable } from './acts.ts' import type { MountedArchives } from '../mpq/mount.ts' /** File path of `UniqueItems.txt` inside MPQ archives. */ export const UNIQUE_ITEMS_TABLE_PATH = 'data\\global\\excel\\UniqueItems.txt' /** One property modifier on a unique item. */ export interface UniqueItemProp { /** Property stat code (e.g. 'str', 'openwounds', 'crush', 'dmg%', 'charged'). */ code: string /** Parameter (e.g. skill name string like 'Oak Sage', or skill ID number like 74). */ par?: string | number /** Minimum roll value. */ min: number /** Maximum roll value. */ max: number } /** One unique item definition from UniqueItems.txt. */ export interface UniqueItem { /** 0-based data row index (0..400). */ id: number /** Primary key and string.tbl translation key (e.g. "The Gnasher"). */ index: string /** Version (0 for Classic, 100 for Expansion). */ version: number /** Whether the item is enabled for generation (385 enabled, 16 disabled). */ enabled: boolean /** Whether this is a ladder-only drop. */ ladder: boolean /** Selection weight among uniques sharing the same base item code. */ rarity: number /** Whether multiple copies of this unique can spawn in the same game. */ nolimit: boolean /** Quality level (qlvl) — item generation ilvl must be >= lvl. */ lvl: number /** Required character level to equip (from `lvl req`). */ levelreq: number /** Base item code (e.g. 'hax', 'rin', 'amu'). */ code: string /** Whether a character may carry only one copy in inventory (e.g. Annihilus, Torch). */ carry1: boolean /** Price multiplier (from `cost mult`). */ costMult: number /** Flat price addition (from `cost add`). */ costAdd: number /** Character color transform code, if any (e.g. 'dyel', 'dgrn'). */ chrtransform?: string /** Inventory icon color transform code, if any. */ invtransform?: string /** Custom inventory graphic file name, if any (e.g. 'invhaxu'). */ invfile?: string /** Item modifiers (up to 12). */ props: UniqueItemProp[] /** Optional base item type name (from `*type`). */ type?: string /** Whether this is an uber quest item (from `*uber`). */ uber?: boolean /** Flippy animation file (from `flippyfile`). */ flippyfile?: string /** Sound played on drop (from `dropsound`). */ dropsound?: string /** Sound played on use (from `usesound`). */ usesound?: string } /** * Case-insensitive, whitespace-tolerant Map that preserves original key casing for iteration. */ export class CaseInsensitiveMap extends Map { private readonly _keyMap = new Map() override get(key: string): V | undefined { if (typeof key !== 'string') return undefined const lower = key.trim().toLowerCase() const original = this._keyMap.get(lower) return original !== undefined ? super.get(original) : super.get(key) } override has(key: string): boolean { if (typeof key !== 'string') return false const lower = key.trim().toLowerCase() return this._keyMap.has(lower) || super.has(key) } override set(key: string, value: V): this { const lower = typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase() const prevOriginal = this._keyMap.get(lower) if (prevOriginal !== undefined && prevOriginal !== key) { super.delete(prevOriginal) } this._keyMap.set(lower, key) return super.set(key, value) } override delete(key: string): boolean { const lower = typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase() const original = this._keyMap.get(lower) this._keyMap.delete(lower) return super.delete(original ?? key) } override clear(): void { this._keyMap.clear() super.clear() } } /** * Parsed UniqueItems table providing list and indexed lookup access. */ export interface UniqueItemsTable { /** All 401 parsed unique item records (including disabled ones). */ readonly all: UniqueItem[] /** Map of unique items by index name (case-insensitive). */ readonly byIndex: Map /** Map of unique items by base item code (case-insensitive). */ readonly byCode: Map /** * Returns all enabled unique items matching the specified base item code. * Excludes disabled items (`enabled === false`). */ getEnabledByCode(code: string): UniqueItem[] /** Total number of unique items parsed (401 in 1.13c). */ readonly length: number /** Direct lookup by index name (case-insensitive). */ get(index: string): UniqueItem | undefined /** Direct lookup by row id (0..400). */ getById(id: number): UniqueItem | undefined /** Iterates over all items. */ [Symbol.iterator](): IterableIterator } /** * Parses a parameter cell (par1..12). * * In Diablo II UniqueItems.txt, a parameter can be: * - Empty string: returns undefined * - Numeric string (e.g. "74", "-5", "0"): returns parsed number * - Skill / stat name string (e.g. "Oak Sage", "Sanctuary", "Nova"): returns preserved string */ export function parsePar(val: string): string | number | undefined { const trimmed = val.trim() if (trimmed === '') return undefined if (/^-?\d+$/.test(trimmed)) { const num = Number(trimmed) if (Number.isFinite(num)) return num } return trimmed } /** * Parses a numeric cell with fallback. */ export function parseNumber(val: string, fallback = 0): number { const trimmed = val.trim() if (trimmed === '') return fallback const num = Number(trimmed) return Number.isFinite(num) ? num : fallback } /** * Parses `UniqueItems.txt` from a raw tab-separated D2Table. * * @param table - Raw parsed tab-separated table. * @returns Parsed `UniqueItemsTable`. */ export function parseUniqueItemsTable(table: D2Table): UniqueItemsTable { const headerMap = new Map() table.header.forEach((name, idx) => { headerMap.set(name.trim().toLowerCase(), idx) }) const getCell = (row: readonly string[], ...names: string[]): string => { for (const name of names) { const idx = headerMap.get(name.toLowerCase()) if (idx !== undefined && idx < row.length) { const val = (row[idx] ?? '').trim() if (val !== '') return val } } return '' } const all: UniqueItem[] = [] const byIndex = new CaseInsensitiveMap() const byCode = new CaseInsensitiveMap() for (const row of table.rows) { const index = getCell(row, 'index') // Filter out rows where index is empty or 'Expansion' separator if (!index || index.toLowerCase() === 'expansion') { continue } const id = all.length const version = parseNumber(getCell(row, 'version'), 0) const enabled = getCell(row, 'enabled') === '1' const ladder = getCell(row, 'ladder') === '1' const rarity = parseNumber(getCell(row, 'rarity'), 0) const nolimit = getCell(row, 'nolimit') === '1' const lvl = parseNumber(getCell(row, 'lvl'), 0) const levelreq = parseNumber(getCell(row, 'lvl req', 'lvlreq', 'levelreq'), 0) const code = getCell(row, 'code') const carry1 = getCell(row, 'carry1') === '1' const costMult = parseNumber(getCell(row, 'cost mult', 'costmult'), 0) const costAdd = parseNumber(getCell(row, 'cost add', 'costadd'), 0) const chrtransform = getCell(row, 'chrtransform') const invtransform = getCell(row, 'invtransform') const invfile = getCell(row, 'invfile') const type = getCell(row, '*type', 'type') const uber = getCell(row, '*uber', 'uber') === '1' const flippyfile = getCell(row, 'flippyfile') const dropsound = getCell(row, 'dropsound') const usesound = getCell(row, 'usesound') const props: UniqueItemProp[] = [] for (let slot = 1; slot <= 12; slot++) { const propCode = getCell(row, `prop${slot}`) if (!propCode) continue const par = parsePar(getCell(row, `par${slot}`)) const min = parseNumber(getCell(row, `min${slot}`), 0) const max = parseNumber(getCell(row, `max${slot}`), 0) const prop: UniqueItemProp = { code: propCode, min, max, ...(par !== undefined ? { par } : {}), } props.push(prop) } const item: UniqueItem = { id, index, version, enabled, ladder, rarity, nolimit, lvl, levelreq, code, carry1, costMult, costAdd, props, ...(chrtransform ? { chrtransform } : {}), ...(invtransform ? { invtransform } : {}), ...(invfile ? { invfile } : {}), ...(type ? { type } : {}), ...(uber ? { uber: true } : {}), ...(flippyfile ? { flippyfile } : {}), ...(dropsound ? { dropsound } : {}), ...(usesound ? { usesound } : {}), } all.push(item) // Primary key lookup: enabled item takes precedence for duplicate indices like Azurewrath const existing = byIndex.get(index) if (!existing || (!existing.enabled && item.enabled)) { byIndex.set(index, item) } // Base code multi-value index if (code) { let codeList = byCode.get(code) if (!codeList) { codeList = [] byCode.set(code, codeList) } codeList.push(item) } } const getEnabledByCode = (itemCode: string): UniqueItem[] => { const list = byCode.get(itemCode) if (!list) return [] return list.filter(item => item.enabled) } return { all, byIndex, byCode, getEnabledByCode, length: all.length, get: (idx: string) => byIndex.get(idx), getById: (id: number) => (id >= 0 && id < all.length ? all[id] : undefined), [Symbol.iterator]() { return all[Symbol.iterator]() }, } } /** * Loads and parses `UniqueItems.txt` from mounted MPQ archives. * * @param archives - Mounted MPQ archives. * @param tablePath - Optional path inside archive (defaults to UNIQUE_ITEMS_TABLE_PATH). * @returns Promise resolving to `UniqueItemsTable`. */ export async function loadUniqueItems( archives: MountedArchives, tablePath = UNIQUE_ITEMS_TABLE_PATH, ): Promise { const bytes = await archives.read(tablePath) const d2Table = parseTable(bytes) return parseUniqueItemsTable(d2Table) }