diablo2-web/src/game/weapons.ts

379 lines
14 KiB
TypeScript

/**
* 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<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 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<string, WeaponBase>
/** 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<WeaponBase>
}
/**
* 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<string, number>()
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<WeaponBase>()
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<WeaponTable> {
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))
}