diablo2-web/src/game/armor.ts

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)
}