feat(items): 加载 Armor.txt (Issue #91)

This commit is contained in:
troytt 2026-09-18 11:25:14 +00:00
commit 6a59edd0cb
2 changed files with 568 additions and 0 deletions

265
src/game/armor.ts Normal file
View File

@ -0,0 +1,265 @@
/**
* 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
}
/**
* 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 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,
}
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)
}

303
tests/armor.test.ts Normal file
View File

@ -0,0 +1,303 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import type { D2Table } from '../src/game/acts.ts'
import { parseTable, cell } from '../src/game/acts.ts'
import type { ItemBase } from '../src/game/items.ts'
import {
ARMOR_TABLE_PATH,
ArmorTable,
parseArmorTable,
loadArmor,
type ArmorBase,
} from '../src/game/armor.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('Armor.txt loader (Issue #91)', () => {
let table: ArmorTable
let rawTable: D2Table | undefined
beforeAll(async () => {
if (hasD2) {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
const bytes = await archives.read(ARMOR_TABLE_PATH)
rawTable = parseTable(bytes)
table = parseArmorTable(rawTable)
} else {
rawTable = createMinimalFallbackTable()
table = parseArmorTable(rawTable)
}
})
it('correctly uses the canonical MPQ table path', () => {
expect(ARMOR_TABLE_PATH).toBe('data\\global\\excel\\Armor.txt')
})
it('loads armor table directly via loadArmor with MountedArchives', async () => {
if (hasD2) {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
const loaded = await loadArmor(archives)
expect(loaded).toBeInstanceOf(ArmorTable)
expect(loaded.all.length).toBe(202)
expect(loaded.byCode.size).toBe(202)
const cap = loaded.get('cap')
expect(cap).toBeDefined()
expect(cap?.code).toBe('cap')
expect(cap?.level).toBe(1)
}
})
it('parses exactly 202 valid data rows from 203 raw rows (excluding Expansion row)', () => {
if (hasD2 && rawTable) {
// 203 raw lines in Armor.txt: 1 header + 203 data/separator rows
expect(rawTable.rows.length).toBe(203)
// Find the Expansion separator row
const expansionRowIndex = rawTable.rows.findIndex(
r => cell(rawTable!, r, 'name').trim() === 'Expansion',
)
expect(expansionRowIndex).toBeGreaterThanOrEqual(0)
expect(cell(rawTable, rawTable.rows[expansionRowIndex]!, 'code').trim()).toBe('')
}
expect(table.all.length).toBe(202)
expect(table.byCode.size).toBe(202)
})
it('verifies 0 failures and 0 duplicate item codes', () => {
const seenCodes = new Set<string>()
const duplicates: string[] = []
for (const armor of table.all) {
if (seenCodes.has(armor.code)) {
duplicates.push(armor.code)
}
seenCodes.add(armor.code)
// Every armor row must have valid identifiers
expect(armor.id).toBeTruthy()
expect(armor.code).toBeTruthy()
expect(armor.name).toBeTruthy()
expect(armor.namestr).toBeTruthy()
expect(armor.type).toBeTruthy()
expect(armor.kind).toBe('armor')
expect(armor.spawnable).toBe(true)
}
expect(duplicates).toEqual([])
expect(seenCodes.size).toBe(table.all.length)
})
it('verifies rarity in 1..4 and level in 1..85 across all armor bases', () => {
let minRarity = Infinity
let maxRarity = -Infinity
let minLevel = Infinity
let maxLevel = -Infinity
for (const armor of table.all) {
expect(armor.rarity).toBeGreaterThanOrEqual(1)
expect(armor.rarity).toBeLessThanOrEqual(4)
if (armor.rarity < minRarity) minRarity = armor.rarity
if (armor.rarity > maxRarity) maxRarity = armor.rarity
expect(armor.level).toBeGreaterThanOrEqual(1)
expect(armor.level).toBeLessThanOrEqual(85)
if (armor.level < minLevel) minLevel = armor.level
if (armor.level > maxLevel) maxLevel = armor.level
}
if (hasD2) {
expect(minRarity).toBe(1)
expect(maxRarity).toBe(4)
expect(minLevel).toBe(1)
expect(maxLevel).toBe(85)
}
})
it('verifies row 0 "cap" attributes match gold standard specifications', () => {
const cap = table.all[0]!
expect(cap).toBeDefined()
expect(cap.id).toBe('cap')
expect(cap.code).toBe('cap')
expect(cap.name).toBe('Cap/hat')
expect(cap.namestr).toBe('cap')
expect(cap.version).toBe(0)
expect(cap.kind).toBe('armor')
expect(cap.spawnable).toBe(true)
expect(cap.rarity).toBe(1)
expect(cap.level).toBe(1)
expect(cap.levelreq).toBe(0)
expect(cap.minac).toBe(3)
expect(cap.maxac).toBe(5)
expect(cap.defense).toBe(5)
expect(cap.reqstr).toBe(0)
expect(cap.durability).toBe(12)
expect(cap.cost).toBe(64)
expect(cap.gambleCost).toBe(3016)
expect(cap.type).toBe('helm')
expect(cap.gemsockets).toBe(2)
expect(cap.gemapplytype).toBe(1)
expect(cap.normcode).toBe('cap')
expect(cap.ubercode).toBe('xap')
expect(cap.ultracode).toBe('uap')
expect(cap.invWidth).toBe(2)
expect(cap.invHeight).toBe(2)
expect(cap.tags).toEqual(['helm'])
expect(cap.maxStack).toBe(1)
expect(cap.value).toBe(64)
expect(cap.damage).toBe(0)
})
it('supports case-insensitive and trimmed lookups via table.get and table.byCode.get', () => {
const cap = table.get('cap')
expect(cap).toBeDefined()
expect(table.get('CAP')).toBe(cap)
expect(table.get(' cap ')).toBe(cap)
expect(table.get('CaP')).toBe(cap)
expect(table.byCode.get('cap')).toBe(cap)
expect(table.byCode.get('CAP')).toBe(cap)
expect(table.byCode.get(' cap ')).toBe(cap)
expect(table.byCode.has('cap')).toBe(true)
expect(table.byCode.has('CAP')).toBe(true)
expect(table.get('unknown_code')).toBeUndefined()
expect(table.byCode.get('unknown_code')).toBeUndefined()
expect(table.byCode.has('unknown_code')).toBe(false)
})
it('correctly links normal, exceptional, and elite tiers (cap -> xap -> uap)', () => {
const cap = table.get('cap')!
const xap = table.get('xap')!
const uap = table.get('uap')!
expect(cap).toBeDefined()
expect(xap).toBeDefined()
expect(uap).toBeDefined()
// Normal tier: Cap (qlvl 1)
expect(cap.level).toBe(1)
expect(cap.normcode).toBe('cap')
expect(cap.ubercode).toBe('xap')
expect(cap.ultracode).toBe('uap')
// Exceptional tier: War Hat (qlvl 34)
expect(xap.name).toBe('War Hat')
expect(xap.level).toBe(34)
expect(xap.normcode).toBe('cap')
expect(xap.ubercode).toBe('xap')
expect(xap.ultracode).toBe('uap')
expect(xap.minac).toBe(45)
expect(xap.maxac).toBe(53)
expect(xap.reqstr).toBe(20)
// Elite tier: Shako (qlvl 58)
expect(uap.name).toBe('Shako')
expect(uap.level).toBe(58)
expect(uap.normcode).toBe('cap')
expect(uap.ubercode).toBe('xap')
expect(uap.ultracode).toBe('uap')
expect(uap.minac).toBe(98)
expect(uap.maxac).toBe(141)
expect(uap.reqstr).toBe(50)
})
it('conforms to ItemBase interface', () => {
const cap = table.get('cap')!
const itemBase: ItemBase = cap
expect(itemBase.id).toBe('cap')
expect(itemBase.name).toBe('Cap/hat')
expect(itemBase.kind).toBe('armor')
expect(itemBase.invWidth).toBe(2)
expect(itemBase.invHeight).toBe(2)
expect(itemBase.maxStack).toBe(1)
expect(itemBase.value).toBe(64)
expect(itemBase.damage).toBe(0)
expect(itemBase.defense).toBe(5)
expect(itemBase.tags).toEqual(['helm'])
expect(itemBase.level).toBe(1)
})
})
/**
* Creates a minimal fallback table for test environments lacking the full MPQ archives.
*/
function createMinimalFallbackTable(): D2Table {
const header = [
'name', 'version', 'compactsave', 'rarity', 'spawnable', 'minac', 'maxac',
'absorbs', 'speed', 'reqstr', 'block', 'durability', 'nodurability', 'level',
'levelreq', 'cost', 'gamble cost', 'code', 'namestr', 'magic lvl', 'auto prefix',
'alternategfx', 'OpenBetaGfx', 'normcode', 'ubercode', 'ultracode', 'spelloffset',
'component', 'invwidth', 'invheight', 'hasinv', 'gemsockets', 'gemapplytype',
'flippyfile', 'invfile', 'uniqueinvfile', 'setinvfile', 'rArm', 'lArm', 'Torso',
'Legs', 'rSPad', 'lSPad', 'useable', 'throwable', 'stackable', 'minstack',
'maxstack', 'type', 'type2', 'dropsound', 'dropsfxframe', 'usesound', 'unique',
'transparent', 'transtbl', 'quivered', 'lightradius', 'belt', 'quest', 'missiletype',
'durwarning', 'qntwarning', 'mindam', 'maxdam', 'StrBonus', 'DexBonus', 'gemoffset',
'bitfield1', 'CharsiMin', 'CharsiMax', 'CharsiMagicMin', 'CharsiMagicMax', 'CharsiMagicLvl',
'GheedMin', 'GheedMax', 'GheedMagicMin', 'GheedMagicMax', 'GheedMagicLvl',
'AkaraMin', 'AkaraMax', 'AkaraMagicMin', 'AkaraMagicMax', 'AkaraMagicLvl',
'FaraMin', 'FaraMax', 'FaraMagicMin', 'FaraMagicMax', 'FaraMagicLvl',
'LysanderMin', 'LysanderMax', 'LysanderMagicMin', 'LysanderMagicMax', 'LysanderMagicLvl',
'DrognanMin', 'DrognanMax', 'DrognanMagicMin', 'DrognanMagicMax', 'DrognanMagicLvl',
'HraltiMin', 'HraltiMax', 'HraltiMagicMin', 'HraltiMagicMax', 'HratliMagicLvl',
'AlkorMin', 'AlkorMax', 'AlkorMagicMin', 'AlkorMagicMax', 'AlkorMagicLvl',
'OrmusMin', 'OrmusMax', 'OrmusMagicMin', 'OrmusMagicMax', 'OrmusMagicLvl',
'ElzixMin', 'ElzixMax', 'ElzixMagicMin', 'ElzixMagicMax', 'ElzixMagicLvl',
'AshearaMin', 'AshearaMax', 'AshearaMagicMin', 'AshearaMagicMax', 'AshearaMagicLvl',
'CainMin', 'CainMax', 'CainMagicMin', 'CainMagicMax', 'CainMagicLvl',
'HalbuMin', 'HalbuMax', 'HalbuMagicMin', 'HalbuMagicMax', 'HalbuMagicLvl',
'JamellaMin', 'JamellaMax', 'JamellaMagicMin', 'JamellaMagicMax', 'JamellaMagicLvl',
'LarzukMin', 'LarzukMax', 'LarzukMagicMin', 'LarzukMagicMax', 'LarzukMagicLvl',
'MalahMin', 'MalahMax', 'MalahMagicMin', 'MalahMagicMax', 'MalahMagicLvl',
'DrehyaMin', 'DrehyaMax', 'DrehyaMagicMin', 'DrehyaMagicMax', 'DrehyaMagicLvl',
'Source Art', 'Game Art', 'Transform', 'InvTrans', 'SkipName', 'NightmareUpgrade',
'HellUpgrade', 'mindam', 'maxdam', 'nameable',
]
const makeRow = (name: string, code: string, level: string, minac: string, maxac: string, reqstr: string, durability: string, sockets: string, norm: string, uber: string, ultra: string, cost: string, gamble: string) => {
const row = new Array(header.length).fill('')
row[header.indexOf('name')] = name
row[header.indexOf('code')] = code
row[header.indexOf('namestr')] = code
row[header.indexOf('version')] = '0'
row[header.indexOf('spawnable')] = '1'
row[header.indexOf('rarity')] = '1'
row[header.indexOf('level')] = level
row[header.indexOf('levelreq')] = '0'
row[header.indexOf('minac')] = minac
row[header.indexOf('maxac')] = maxac
row[header.indexOf('reqstr')] = reqstr
row[header.indexOf('durability')] = durability
row[header.indexOf('cost')] = cost
row[header.indexOf('gamble cost')] = gamble
row[header.indexOf('type')] = 'helm'
row[header.indexOf('gemsockets')] = sockets
row[header.indexOf('gemapplytype')] = '1'
row[header.indexOf('normcode')] = norm
row[header.indexOf('ubercode')] = uber
row[header.indexOf('ultracode')] = ultra
row[header.indexOf('invwidth')] = '2'
row[header.indexOf('invheight')] = '2'
return row
}
const rows: string[][] = [
makeRow('Cap/hat', 'cap', '1', '3', '5', '0', '12', '2', 'cap', 'xap', 'uap', '64', '3016'),
makeRow('War Hat', 'xap', '34', '45', '53', '20', '12', '2', 'cap', 'xap', 'uap', '500', '10000'),
makeRow('Shako', 'uap', '58', '98', '141', '50', '12', '2', 'cap', 'xap', 'uap', '2000', '50000'),
]
return { header, rows }
}