/** * Weapons loader and parser (`data/global/excel/Weapons.txt`). * * Parses Diablo II 1.13c weapons into typed `WeaponBase` records conforming to * `ItemBase` in `src/game/items.ts`. * * In vanilla 1.13c, Weapons.txt contains 307 raw lines: * - 1 'Expansion' section separator row (row index 133, name = 'Expansion') * - 306 valid weapon data rows across normal, exceptional, elite, and quest/class items. * * Special parsing considerations: * - Column index 18 has an empty string header `""` (between `maxmisdam` and `rangeadder`). * The parser ignores empty header keys to prevent collision with `cell(table, row, '')`. * - 15 items (quest items like Wirt's Leg, Horadric Staff, throwing potions) have empty rarity, * which parses to `rarity: 0` (indicating exclusion from auto-generated TreasureClasses). */ import type { ItemBase } from './items.ts' import type { D2Table } from './acts.ts' import { parseTable } from './acts.ts' import type { MountedArchives } from '../mpq/mount.ts' /** File path of Weapons.txt inside MPQ archives. */ export const WEAPONS_TABLE_PATH = 'data\\global\\excel\\Weapons.txt' /** * Parsed weapon base definition from `Weapons.txt`, conforming to `ItemBase`. */ export interface WeaponBase extends ItemBase { /** Table id / item code (e.g. 'hax', 'crs'). */ readonly id: string /** 3-4 letter item code (e.g. 'hax', 'crs'). */ readonly code: string /** Display name from the table (e.g. 'Hand Axe', 'Crystal Sword'). */ readonly name: string /** String lookup key from namestr column. */ readonly namestr: string /** Version number (0 for classic, 100 for expansion). */ readonly version: number /** Kind discriminator, always 'weapon'. */ readonly kind: 'weapon' /** Whether the weapon can spawn as random loot or at vendors. */ readonly spawnable: boolean /** Rarity weight (0..5; 15 quest/special items have rarity=0, excluded from auto-TC). */ readonly rarity: number /** Item level (0..86). */ readonly level: number /** Character level requirement to equip. */ readonly levelreq: number /** One-handed minimum physical damage. */ readonly mindam: number /** One-handed maximum physical damage. */ readonly maxdam: number /** Two-handed minimum physical damage (if two-handed). */ readonly twoHandedMindam?: number /** Two-handed maximum physical damage (if two-handed). */ readonly twoHandedMaxdam?: number /** Whether this weapon is a two-handed weapon. */ readonly twoHanded: boolean /** Minimum thrown / missile physical damage. */ readonly minMisDam?: number /** Maximum thrown / missile physical damage. */ readonly maxMisDam?: number /** Base weapon attack speed modifier (lower/negative is faster). */ readonly speed: number /** Strength required to equip. */ readonly reqstr: number /** Dexterity required to equip. */ readonly reqdex: number /** Base durability. */ readonly durability: number /** Base gold purchase / sell cost. */ readonly cost: number /** Gold gambling cost. */ readonly gambleCost: number /** Primary item type code (e.g. 'axe', 'swor', 'bow'). */ readonly type: string /** Secondary item type code, if any. */ readonly type2?: string /** Weapon animation class for one-handed wielding (e.g. '1hs', '1ht'). */ readonly wclass: string /** Weapon animation class for two-handed wielding (e.g. '2hs', '2ht', 'bow'). */ readonly twoHandedWClass?: string /** Whether items of this base are stackable (e.g. throwing weapons). */ readonly stackable: boolean /** Minimum stack quantity when spawned. */ readonly minstack?: number /** Maximum stack capacity. */ readonly maxstack?: number /** Weapon melee attack range modifier (hit reach = 1 + rangeadder). */ readonly rangeadder: number /** Code of the normal tier version. */ readonly normcode: string /** Code of the exceptional (uber) tier version. */ readonly ubercode: string /** Code of the elite (ultra) tier version. */ readonly ultracode: string /** Maximum number of sockets this base can spawn with. */ readonly gemsockets: number /** Inventory width in cells (1 or 2). */ readonly invWidth: number /** Inventory height in cells (1 to 4). */ readonly invHeight: number /** Item tags affixes match against: [type, type2] if type2 exists, else [type]. */ readonly tags: string[] /** Peak damage value for ItemBase compliance (equals maxdam). */ readonly damage: number /** Base gold value for ItemBase compliance (equals cost). */ readonly value: number /** Defense rating for ItemBase compliance (always 0 for weapons). */ readonly defense: number /** Maximum stack capacity for ItemBase compliance (maxstack ?? 1). */ readonly maxStack: number /** Optional AutoMagic group from 'auto prefix' column (e.g. 300, 302, 303). */ readonly autoPrefix?: number /** Authentic drop SFX sound linker from dropsound column (e.g. 'item_sword', 'item_bow'). */ readonly dropsound?: string | undefined /** Authentic frame at which dropsound triggers (from dropsfxframe column). */ readonly dropsfxframe?: number | undefined } /** * Case-insensitive, whitespace-tolerant Map for weapon lookups by code. */ export 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 weapons table providing array and indexed lookup access. */ export interface WeaponTable { /** Map of weapons indexed by 3-4 letter code (case-insensitive). */ readonly byCode: Map /** All parsed weapon bases in table order (306 items in vanilla 1.13c). */ readonly all: WeaponBase[] /** Get a weapon base by code (case-insensitive). */ get(code: string): WeaponBase | undefined /** Total count of weapons. */ readonly length: number /** Allows iteration over all weapon bases. */ [Symbol.iterator](): IterableIterator } /** * Parse a numeric cell value, returning fallback if empty or NaN. */ function parseNumber(value: string, fallback = 0): number { const trimmed = value.trim() if (!trimmed) return fallback const n = Number.parseInt(trimmed, 10) return Number.isNaN(n) ? fallback : n } /** * Parse an optional numeric cell value, returning undefined if empty or NaN. */ function parseOptionalNumber(value: string): number | undefined { const trimmed = value.trim() if (!trimmed) return undefined const n = Number.parseInt(trimmed, 10) return Number.isNaN(n) ? undefined : n } /** * Reads a column out of a row in a Weapons D2Table safely, avoiding column 18 collision. */ export function getWeaponCell(table: D2Table, row: readonly string[], column: string): string { if (!column || !column.trim()) return '' const target = column.trim().toLowerCase() const idx = table.header.findIndex((h, i) => i !== 18 && h.trim().toLowerCase() === target) return idx === -1 ? '' : (row[idx] ?? '').trim() } /** * Parses a Weapons D2Table (or raw bytes/TSV text) into a `WeaponTable`. * * @param table - the tab-separated D2Table or bytes or string. * @returns the parsed WeaponTable. */ export function parseWeaponsTable(table: D2Table): WeaponTable export function parseWeaponsTable(bytes: Uint8Array): WeaponTable export function parseWeaponsTable(tsv: string): WeaponTable export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponTable { let table: D2Table if (typeof input === 'string') { table = parseTable(new TextEncoder().encode(input)) } else if (input instanceof Uint8Array) { table = parseTable(input) } else { table = input } // Map non-empty header column names to their 0-based column index. // CAUTION: Column index 18 in vanilla Weapons.txt has an empty header "" (between maxmisdam and rangeadder). // We MUST ignore empty headers so empty column lookups never collide with column 18. const colIndex = new Map() for (let i = 0; i < table.header.length; i++) { const h = table.header[i]?.trim().toLowerCase() if (h && h.length > 0) { colIndex.set(h, i) } } const getCell = (row: readonly string[], col: string): string => { const key = col.trim().toLowerCase() if (!key) return '' const idx = colIndex.get(key) if (idx === undefined) return '' return (row[idx] ?? '').trim() } const all: WeaponBase[] = [] const byCode = new CaseInsensitiveMap() for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex++) { const row = table.rows[rowIndex] if (!row) continue const name = getCell(row, 'name') const code = getCell(row, 'code') // Filter out the 'Expansion' section separator row and rows without a code if (name.toLowerCase() === 'expansion' || !code) { continue } const namestr = getCell(row, 'namestr') const version = parseNumber(getCell(row, 'version'), 0) const spawnable = getCell(row, 'spawnable') === '1' const rawRarity = getCell(row, 'rarity') // 15 items have empty rarity in 1.13c; they must be parsed as 0 (excluded from auto-TC) const rarity = rawRarity ? parseNumber(rawRarity, 0) : 0 const level = parseNumber(getCell(row, 'level'), 0) const levelreq = parseNumber(getCell(row, 'levelreq'), 0) const mindam = parseNumber(getCell(row, 'mindam'), 0) const maxdam = parseNumber(getCell(row, 'maxdam'), 0) const twoHandedMindam = parseOptionalNumber(getCell(row, '2handmindam')) const twoHandedMaxdam = parseOptionalNumber(getCell(row, '2handmaxdam')) const twoHanded = getCell(row, '2handed') === '1' const minMisDam = parseOptionalNumber(getCell(row, 'minmisdam')) const maxMisDam = parseOptionalNumber(getCell(row, 'maxmisdam')) const speed = parseNumber(getCell(row, 'speed'), 0) const reqstr = parseNumber(getCell(row, 'reqstr'), 0) const reqdex = parseNumber(getCell(row, 'reqdex'), 0) const durability = parseNumber(getCell(row, 'durability'), 0) const cost = parseNumber(getCell(row, 'cost'), 0) const gambleCost = parseNumber(getCell(row, 'gamble cost'), 0) const type = getCell(row, 'type') const rawType2 = getCell(row, 'type2') const type2 = rawType2 ? rawType2 : undefined const wclass = getCell(row, 'wclass') const raw2hWClass = getCell(row, '2handedwclass') const twoHandedWClass = raw2hWClass ? raw2hWClass : undefined const stackable = getCell(row, 'stackable') === '1' const minstack = parseOptionalNumber(getCell(row, 'minstack')) const maxstack = parseOptionalNumber(getCell(row, 'maxstack')) const rangeadder = parseNumber(getCell(row, 'rangeadder'), 0) const normcode = getCell(row, 'normcode') const ubercode = getCell(row, 'ubercode') const ultracode = getCell(row, 'ultracode') const gemsockets = parseNumber(getCell(row, 'gemsockets'), 0) const invWidth = parseNumber(getCell(row, 'invwidth'), 0) const invHeight = parseNumber(getCell(row, 'invheight'), 0) const autoPrefix = parseOptionalNumber(getCell(row, 'auto prefix')) const tags = type2 ? [type, type2] : [type] const damage = maxdam const dropsound = getCell(row, 'dropsound') || undefined const dropsfxframe = parseOptionalNumber(getCell(row, 'dropsfxframe')) const weapon: WeaponBase = { id: code, code, name, namestr, version, kind: 'weapon', spawnable, rarity, level, levelreq, mindam, maxdam, ...(twoHandedMindam !== undefined ? { twoHandedMindam } : {}), ...(twoHandedMaxdam !== undefined ? { twoHandedMaxdam } : {}), twoHanded, ...(minMisDam !== undefined ? { minMisDam } : {}), ...(maxMisDam !== undefined ? { maxMisDam } : {}), speed, reqstr, reqdex, durability, cost, gambleCost, type, ...(type2 !== undefined ? { type2 } : {}), wclass, ...(twoHandedWClass !== undefined ? { twoHandedWClass } : {}), stackable, ...(minstack !== undefined ? { minstack } : {}), ...(maxstack !== undefined ? { maxstack } : {}), rangeadder, normcode, ubercode, ultracode, gemsockets, invWidth, invHeight, tags, damage, value: cost, defense: 0, maxStack: maxstack ?? 1, ...(autoPrefix !== undefined && autoPrefix > 0 ? { autoPrefix } : {}), ...(dropsound ? { dropsound } : {}), ...(dropsfxframe !== undefined ? { dropsfxframe } : {}), } all.push(weapon) byCode.set(code, weapon) } return { byCode, all, get: (code: string) => byCode.get(code), get length() { return all.length }, [Symbol.iterator]: () => all[Symbol.iterator](), } } /** * Loads and parses `Weapons.txt` from mounted MPQ archives. * * @param archives - Mounted MPQ archives. * @returns Promise resolving to `WeaponTable`. */ export async function loadWeapons(archives: MountedArchives): Promise { const candidatePaths = [ WEAPONS_TABLE_PATH, 'data/global/excel/Weapons.txt', 'data\\global\\excel\\weapons.txt', 'data/global/excel/weapons.txt', ] let bytes: Uint8Array | undefined for (const p of candidatePaths) { if (archives.has(p)) { bytes = await archives.read(p) break } } if (!bytes) { bytes = await archives.read(WEAPONS_TABLE_PATH) } return parseWeaponsTable(parseTable(bytes)) }