272 lines
8.6 KiB
TypeScript
272 lines
8.6 KiB
TypeScript
/**
|
|
* 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<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 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<string, ArmorBase>
|
|
|
|
constructor(all: ArmorBase[], byCode: Map<string, ArmorBase>) {
|
|
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<string, number>()
|
|
const lowerMap = new Map<string, number>()
|
|
|
|
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<ArmorBase>()
|
|
|
|
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<ArmorTable> {
|
|
const bytes = await archives.read(ARMOR_TABLE_PATH)
|
|
const d2Table = parseTable(bytes)
|
|
return parseArmorTable(d2Table)
|
|
}
|