feat(items): 加载 Weapons.txt (Issue #92)

This commit is contained in:
troytt 2026-09-18 11:27:13 +00:00
parent 7187154ac8
commit 98daef5adf
2 changed files with 797 additions and 0 deletions

366
src/game/weapons.ts Normal file
View File

@ -0,0 +1,366 @@
/**
* 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
}
/**
* 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 tags = type2 ? [type, type2] : [type]
const damage = maxdam
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,
}
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))
}

431
tests/weapons.test.ts Normal file
View File

@ -0,0 +1,431 @@
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 { cell, parseTable } from '../src/game/acts.ts'
import type { ItemBase } from '../src/game/items.ts'
import {
parseWeaponsTable,
loadWeapons,
getWeaponCell,
WEAPONS_TABLE_PATH,
type WeaponTable,
type WeaponBase,
} from '../src/game/weapons.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('Unit: Standalone Weapons Table Parsing', () => {
const syntheticHeader = [
'name', 'type', 'type2', 'code', 'alternateGfx', 'namestr', 'version', 'compactsave',
'rarity', 'spawnable', 'mindam', 'maxdam', '1or2handed', '2handed', '2handmindam',
'2handmaxdam', 'minmisdam', 'maxmisdam', '', 'rangeadder', 'speed', 'StrBonus',
'DexBonus', 'reqstr', 'reqdex', 'durability', 'nodurability', 'level', 'levelreq',
'cost', 'gamble cost', 'magic lvl', 'auto prefix', 'OpenBetaGfx', 'normcode', 'ubercode',
'ultracode', 'wclass', '2handedwclass', 'component', 'hit class', 'invwidth', 'invheight',
'stackable', 'minstack', 'maxstack', 'spawnstack', 'flippyfile', 'invfile', 'uniqueinvfile',
'setinvfile', 'hasinv', 'gemsockets',
].join('\t')
it('parses synthetic rows, handles empty col 18, and filters Expansion row', () => {
const rawTsv = [
syntheticHeader,
// Regular 1H weapon
['Hand Axe', 'axe', '', 'hax', 'hax', 'hax', '0', '', '3', '1', '3', '6', '', '', '', '', '', '', '99', '0', '0', '100', '', '0', '0', '28', '', '3', '0', '170', '4510', '', '', 'hax', 'hax', '9ha', '7ha', '1hs', '1hs', '5', '1hsl', '1', '3', '', '', '', '', 'flphax', 'invhax', 'invhaxu', 'invhaxu', '1', '2'].join('\t'),
// Expansion separator row
['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''].join('\t'),
// 2H weapon
['Two-Handed Sword', 'swor', '', '2hs', '2hs', '2hs', '0', '', '2', '1', '2', '9', '1', '1', '8', '17', '', '', '', '2', '0', '100', '', '35', '0', '44', '', '10', '0', '478', '9560', '', '', '2hs', '2hs', '92h', '72h', '1hs', '2hs', '5', '1hsl', '1', '4', '', '', '', '', 'flp2hs', 'inv2hs', 'inv2hsu', 'inv2hsu', '1', '3'].join('\t'),
// Quest / special item with empty rarity
['Wirt\'s Leg', 'club', '', 'leg', 'leg', 'leg', '0', '', '', '0', '1', '8', '', '', '', '', '', '', '15', '0', '20', '100', '', '0', '0', '66', '', '1', '0', '1', '10', '', '', 'leg', 'leg', '', '', '1hs', '1hs', '5', '1hsl', '1', '3', '', '', '', '', 'flpleg', 'invleg', 'invleg', 'invleg', '1', '3'].join('\t'),
// Stackable thrown weapon
['Throwing Knife', 'tkni', '', 'tkf', 'tkf', 'tkf', '0', '', '3', '1', '2', '3', '', '', '', '', '4', '9', '15', '0', '0', '100', '', '0', '21', '0', '1', '2', '0', '160', '3200', '', '', 'tkf', 'tkf', '9tk', '7tk', '1ht', '1ht', '5', '1ht', '1', '2', '1', '40', '160', '100', 'flptkf', 'invtkf', 'invtkfu', 'invtkfu', '1', '0'].join('\t'),
].join('\n')
const table = parseWeaponsTable(rawTsv)
expect(table.all).toHaveLength(4)
expect(table.length).toBe(4)
expect(table.get('Expansion')).toBeUndefined()
// 1. Regular 1H weapon (hax)
const hax = table.get('hax')!
expect(hax).toBeDefined()
expect(hax.name).toBe('Hand Axe')
expect(hax.rarity).toBe(3)
expect(hax.spawnable).toBe(true)
expect(hax.rangeadder).toBe(0)
// Verify col 18 value '99' did NOT pollute rangeadder or maxMisDam
expect(hax.rangeadder).not.toBe(99)
expect(hax.maxMisDam).toBeUndefined()
// 2. 2H weapon (2hs)
const twoHand = table.get('2hs')!
expect(twoHand).toBeDefined()
expect(twoHand.twoHanded).toBe(true)
expect(twoHand.mindam).toBe(2)
expect(twoHand.maxdam).toBe(9)
expect(twoHand.twoHandedMindam).toBe(8)
expect(twoHandedMaxdam(twoHand)).toBe(17)
expect(twoHand.twoHandedWClass).toBe('2hs')
// 3. Quest item with empty rarity (leg)
const leg = table.get('leg')!
expect(leg).toBeDefined()
expect(leg.rarity).toBe(0) // Empty rarity MUST be parsed as 0
expect(leg.spawnable).toBe(false)
expect(leg.ubercode).toBe('')
expect(leg.ultracode).toBe('')
// 4. Stackable thrown weapon (tkf)
const tkf = table.get('tkf')!
expect(tkf).toBeDefined()
expect(tkf.stackable).toBe(true)
expect(tkf.minstack).toBe(40)
expect(tkf.maxstack).toBe(160)
expect(tkf.maxStack).toBe(160)
expect(tkf.minMisDam).toBe(4)
expect(tkf.maxMisDam).toBe(9)
// ItemBase compliance
const itemBase: ItemBase = hax
expect(itemBase.id).toBe('hax')
expect(itemBase.kind).toBe('weapon')
expect(itemBase.defense).toBe(0)
expect(itemBase.damage).toBe(6)
expect(itemBase.value).toBe(170)
})
it('demonstrates column index 18 hazard in acts.ts cell() vs getWeaponCell()', () => {
const rawTsv = [
syntheticHeader,
['Throwing Knife', 'tkni', '', 'tkf', 'tkf', 'tkf', '0', '', '3', '1', '2', '3', '', '', '', '', '4', '9', '15', '0', '0', '100', '', '0', '21', '0', '1', '2', '0', '160', '3200', '', '', 'tkf', 'tkf', '9tk', '7tk', '1ht', '1ht', '5', '1ht', '1', '2', '1', '40', '160', '100', 'flptkf', 'invtkf', 'invtkfu', 'invtkfu', '1', '0'].join('\t'),
].join('\n')
const d2Table = parseTable(new TextEncoder().encode(rawTsv))
const row = d2Table.rows[0]!
// In raw acts.ts cell(), querying '' returns column 18 because header[18] is ''
expect(d2Table.header[18]).toBe('')
expect(cell(d2Table, row, '')).toBe('15') // Confirms the dangerous hazard!
// In getWeaponCell(), querying '' safely returns empty string
expect(getWeaponCell(d2Table, row, '')).toBe('')
})
})
function twoHandedMaxdam(w: WeaponBase): number | undefined {
return w.twoHandedMaxdam
}
describe.skipIf(!hasD2)('Weapons.txt 1.13c Integration Tests (Issue #92)', () => {
let archives: MountedArchives
let rawTable: D2Table
let weapons: WeaponTable
beforeAll(async () => {
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}`)))
}
weapons = await loadWeapons(archives)
const bytes = await archives.read(WEAPONS_TABLE_PATH)
rawTable = parseTable(bytes)
})
it('loads weapons via canonical path WEAPONS_TABLE_PATH', () => {
expect(WEAPONS_TABLE_PATH).toBe('data\\global\\excel\\Weapons.txt')
})
it('verifies 307 raw lines - 1 Expansion row = 306 real data rows', () => {
// Exactly 307 raw rows in file
expect(rawTable.rows.length).toBe(307)
// Row index 175 is the Expansion separator
const expansionIndex = rawTable.rows.findIndex(r => r[0] === 'Expansion')
expect(expansionIndex).toBe(175)
const expansionRow = rawTable.rows[expansionIndex]!
expect(expansionRow[0]).toBe('Expansion')
expect(expansionRow[3]).toBe('') // Code is empty
// Exactly 306 data rows parsed
expect(weapons.all).toHaveLength(306)
expect(weapons.length).toBe(306)
expect(weapons.byCode.size).toBe(306)
// Verify Expansion is not in parsed table
expect(weapons.get('Expansion')).toBeUndefined()
expect(weapons.byCode.has('Expansion')).toBe(false)
})
it('verifies 0 failures and 0 duplicate codes across all 306 weapons', () => {
const codeSet = new Set<string>()
for (const w of weapons.all) {
expect(w.code).toBeTruthy()
expect(w.code.length).toBeGreaterThanOrEqual(3)
expect(w.name).toBeTruthy()
expect(w.type).toBeTruthy()
expect(w.kind).toBe('weapon')
expect(Number.isNaN(w.level)).toBe(false)
expect(Number.isNaN(w.mindam)).toBe(false)
expect(Number.isNaN(w.maxdam)).toBe(false)
expect(Number.isNaN(w.cost)).toBe(false)
expect(Number.isNaN(w.gambleCost)).toBe(false)
expect(Number.isNaN(w.speed)).toBe(false)
expect(codeSet.has(w.code), `Duplicate weapon code found: ${w.code}`).toBe(false)
codeSet.add(w.code)
}
expect(codeSet.size).toBe(306)
})
it('verifies column index 18 empty header does not pollute any field', () => {
expect(rawTable.header[18]).toBe('')
// Exactly 166 rows in vanilla 1.13c have values in column 18
const col18Rows = rawTable.rows.filter(r => r[0] !== 'Expansion' && r[18] !== '')
expect(col18Rows.length).toBe(166)
// Test specific weapon with column 18 value: 'jav' (Javelin) has '15' in column 18
const jav = weapons.get('jav')!
expect(jav).toBeDefined()
expect(jav.name).toBe('Javelin')
// Column 17 is maxmisdam = 14
expect(jav.maxMisDam).toBe(14)
// Column 19 is rangeadder = 2 (NOT 15 from column 18)
expect(jav.rangeadder).toBe(2)
expect(jav.speed).toBe(-10)
expect(jav.mindam).toBe(1)
expect(jav.maxdam).toBe(5)
expect(jav.twoHanded).toBe(false)
// Test 'pil' (Pilum): col 17 = 20, col 18 = 15, col 19 = 2
const pil = weapons.get('pil')!
expect(pil).toBeDefined()
expect(pil.maxMisDam).toBe(20)
expect(pil.rangeadder).toBe(2)
// Lookup of empty string must be undefined
expect(weapons.get('')).toBeUndefined()
expect(weapons.byCode.get('')).toBeUndefined()
})
it('verifies exactly 15 items have rarity === 0', () => {
const zeroRarityItems = weapons.all.filter(w => w.rarity === 0)
expect(zeroRarityItems).toHaveLength(15)
const zeroRarityCodes = zeroRarityItems.map(w => w.code).sort()
// Expected 15 quest items, special weapons, and throwing potions
const expectedZeroRarityCodes = [
'd33', // decoy dagger
'g33', // Gidbinn
'gpl', // Strangling Gas Potion
'gpm', // Choking Gas Potion
'gps', // Rancid Gas Potion
'hdm', // Horadric Malus
'hfh', // Hellforge Hammer
'hst', // Horadric Staff
'leg', // Wirt's Leg
'msf', // Staff of the Kings
'opl', // Fulminating Potion
'opm', // Exploding Potion
'ops', // Oil Potion
'qf1', // KhalimFlail
'qf2', // SuperKhalimFlail
].sort()
expect(zeroRarityCodes).toEqual(expectedZeroRarityCodes)
// All remaining 291 items have rarity between 1 and 5
const nonZeroRarityItems = weapons.all.filter(w => w.rarity > 0)
expect(nonZeroRarityItems).toHaveLength(291)
for (const w of nonZeroRarityItems) {
expect(w.rarity).toBeGreaterThanOrEqual(1)
expect(w.rarity).toBeLessThanOrEqual(5)
}
})
it('verifies spawnable counts: exactly 297/306 are spawnable', () => {
const spawnableItems = weapons.all.filter(w => w.spawnable)
expect(spawnableItems).toHaveLength(297)
const nonSpawnableItems = weapons.all.filter(w => !w.spawnable)
expect(nonSpawnableItems).toHaveLength(9)
// 9 non-spawnable items are the unique quest weapons
const nonSpawnableCodes = nonSpawnableItems.map(w => w.code).sort()
expect(nonSpawnableCodes).toEqual(['d33', 'g33', 'hdm', 'hfh', 'hst', 'leg', 'msf', 'qf1', 'qf2'].sort())
})
it('verifies level ranges from 0 to 86', () => {
const levels = weapons.all.map(w => w.level)
expect(Math.min(...levels)).toBe(0)
expect(Math.max(...levels)).toBe(86)
})
it('sample check: hax (Hand Axe) damage, requirements, and codes', () => {
const hax = weapons.get('hax')!
expect(hax).toBeDefined()
expect(hax).toMatchObject({
id: 'hax',
code: 'hax',
name: 'Hand Axe',
namestr: 'hax',
version: 0,
kind: 'weapon',
spawnable: true,
rarity: 3,
level: 3,
levelreq: 0,
mindam: 3,
maxdam: 6,
twoHanded: false,
speed: 0,
reqstr: 0,
reqdex: 0,
durability: 28,
cost: 170,
gambleCost: 4510,
type: 'axe',
wclass: '1hs',
twoHandedWClass: '1hs',
stackable: false,
rangeadder: 0,
normcode: 'hax',
ubercode: '9ha',
ultracode: '7ha',
gemsockets: 2,
invWidth: 1,
invHeight: 3,
tags: ['axe'],
damage: 6,
value: 170,
defense: 0,
maxStack: 1,
})
expect(hax.twoHandedMindam).toBeUndefined()
expect(hax.twoHandedMaxdam).toBeUndefined()
expect(hax.minMisDam).toBeUndefined()
expect(hax.maxMisDam).toBeUndefined()
expect(hax.type2).toBeUndefined()
expect(hax.minstack).toBeUndefined()
expect(hax.maxstack).toBeUndefined()
})
it('sample check: crs (Crystal Sword) damage, requirements, and codes', () => {
const crs = weapons.get('crs')!
expect(crs).toBeDefined()
expect(crs).toMatchObject({
id: 'crs',
code: 'crs',
name: 'Crystal Sword',
namestr: 'crs',
version: 0,
kind: 'weapon',
spawnable: true,
rarity: 2,
level: 11,
levelreq: 0,
mindam: 5,
maxdam: 15,
twoHanded: false,
speed: 0,
reqstr: 43,
reqdex: 0,
durability: 20,
cost: 2127,
gambleCost: 31397,
type: 'swor',
wclass: '1hs',
twoHandedWClass: '1hs',
stackable: false,
rangeadder: 1,
normcode: 'crs',
ubercode: '9cr',
ultracode: '7cr',
gemsockets: 6,
invWidth: 2,
invHeight: 3,
tags: ['swor'],
damage: 15,
value: 2127,
defense: 0,
maxStack: 1,
})
expect(crs.twoHandedMindam).toBeUndefined()
expect(crs.twoHandedMaxdam).toBeUndefined()
expect(crs.minMisDam).toBeUndefined()
expect(crs.maxMisDam).toBeUndefined()
})
it('verifies two-handed weapon archetypes (2hs, bow)', () => {
// Two-Handed Sword (2hs)
const twoHandedSword = weapons.get('2hs')!
expect(twoHandedSword).toBeDefined()
expect(twoHandedSword.twoHanded).toBe(true)
expect(twoHandedSword.mindam).toBe(2)
expect(twoHandedSword.maxdam).toBe(9)
expect(twoHandedSword.twoHandedMindam).toBe(8)
expect(twoHandedSword.twoHandedMaxdam).toBe(17)
expect(twoHandedSword.wclass).toBe('1hs')
expect(twoHandedSword.twoHandedWClass).toBe('2hs')
// Short Bow (sbw)
const bow = weapons.get('sbw')!
expect(bow).toBeDefined()
expect(bow.twoHanded).toBe(true)
expect(bow.mindam).toBe(0)
expect(bow.maxdam).toBe(0)
expect(bow.twoHandedMindam).toBe(1)
expect(bow.twoHandedMaxdam).toBe(4)
expect(bow.wclass).toBe('bow')
expect(bow.twoHandedWClass).toBe('bow')
})
it('verifies stackable missile weapon archetype (tkf)', () => {
const tkf = weapons.get('tkf')!
expect(tkf).toBeDefined()
expect(tkf.stackable).toBe(true)
expect(tkf.minstack).toBe(40)
expect(tkf.maxstack).toBe(160)
expect(tkf.maxStack).toBe(160)
expect(tkf.mindam).toBe(2)
expect(tkf.maxdam).toBe(3)
expect(tkf.minMisDam).toBe(4)
expect(tkf.maxMisDam).toBe(9)
})
it('supports case-insensitive and trimmed code lookups', () => {
const hax = weapons.get('hax')!
expect(weapons.get('HAX')).toBe(hax)
expect(weapons.get(' hax ')).toBe(hax)
expect(weapons.byCode.get('HAX')).toBe(hax)
expect(weapons.byCode.get(' Hax ')).toBe(hax)
expect(weapons.byCode.has('HAX')).toBe(true)
const crs = weapons.get('crs')!
expect(weapons.get('CRS')).toBe(crs)
expect(weapons.byCode.get('CRS')).toBe(crs)
})
it('allows for-of iteration over WeaponTable', () => {
let count = 0
for (const w of weapons) {
expect(w.kind).toBe('weapon')
count++
}
expect(count).toBe(306)
})
it('conforms to ItemBase interface in items.ts', () => {
const hax: ItemBase = weapons.get('hax')!
expect(hax.id).toBe('hax')
expect(hax.kind).toBe('weapon')
expect(hax.damage).toBe(6)
expect(hax.defense).toBe(0)
expect(hax.invWidth).toBe(1)
expect(hax.invHeight).toBe(3)
expect(hax.tags).toEqual(['axe'])
})
})