276 lines
9.1 KiB
TypeScript
276 lines
9.1 KiB
TypeScript
/**
|
|
* 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<V> extends Map<string, V> {
|
|
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<string, MiscBase>
|
|
/** 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<MiscBase>()
|
|
const runesByNumber = new Map<number, MiscBase>()
|
|
|
|
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<MiscTable> {
|
|
const bytes = await archives.read(MISC_TABLE_PATH)
|
|
const d2Table = parseTable(bytes)
|
|
return parseMiscTable(d2Table)
|
|
}
|