feat(items): 加载 UniqueItems.txt (Issue #97)

This commit is contained in:
troytt 2026-09-18 11:29:08 +00:00
parent 7187154ac8
commit 6b18783fbc
2 changed files with 678 additions and 0 deletions

333
src/game/unique-items.ts Normal file
View File

@ -0,0 +1,333 @@
/**
* Diablo II unique items loader (`UniqueItems.txt`).
*
* Gold standard reference: 1.13c MPQ `data/global/excel/UniqueItems.txt`.
*
* In Diablo II, unique items represent specific, named items with fixed
* affixes and randomized ranges. Each item is tied to a base item (`code`)
* and carries a quality level (`lvl`) and character level requirement (`levelreq`).
*
* Key data properties:
* - 403 raw lines in 1.13c; minus the empty index row and 'Expansion' section
* separator row = exactly 401 real unique item records.
* - Exactly 385 items are enabled (`enabled === 1`). 16 items have `enabled === 0`
* or blank, and are flagged as `enabled = false` so they do not enter the
* runtime generation pool (e.g. classic Azurewrath, Constricting Ring, Gore Ripper).
* - Multi-value index: multiple unique items can share the same base item code
* (e.g., rings `rin` have 10 uniques in 1.13c, 9 enabled; amulets `amu` have 11 uniques).
* - Up to 12 properties per item (`prop1..12`, `par1..12`, `min1..12`, `max1..12`).
* Parameters (`par`) can be numeric (skill IDs) or skill name strings (e.g. "Oak Sage").
*/
import type { D2Table } from './acts.ts'
import { parseTable } from './acts.ts'
import type { MountedArchives } from '../mpq/mount.ts'
/** File path of `UniqueItems.txt` inside MPQ archives. */
export const UNIQUE_ITEMS_TABLE_PATH = 'data\\global\\excel\\UniqueItems.txt'
/** One property modifier on a unique item. */
export interface UniqueItemProp {
/** Property stat code (e.g. 'str', 'openwounds', 'crush', 'dmg%', 'charged'). */
code: string
/** Parameter (e.g. skill name string like 'Oak Sage', or skill ID number like 74). */
par?: string | number
/** Minimum roll value. */
min: number
/** Maximum roll value. */
max: number
}
/** One unique item definition from UniqueItems.txt. */
export interface UniqueItem {
/** 0-based data row index (0..400). */
id: number
/** Primary key and string.tbl translation key (e.g. "The Gnasher"). */
index: string
/** Version (0 for Classic, 100 for Expansion). */
version: number
/** Whether the item is enabled for generation (385 enabled, 16 disabled). */
enabled: boolean
/** Whether this is a ladder-only drop. */
ladder: boolean
/** Selection weight among uniques sharing the same base item code. */
rarity: number
/** Whether multiple copies of this unique can spawn in the same game. */
nolimit: boolean
/** Quality level (qlvl) — item generation ilvl must be >= lvl. */
lvl: number
/** Required character level to equip (from `lvl req`). */
levelreq: number
/** Base item code (e.g. 'hax', 'rin', 'amu'). */
code: string
/** Whether a character may carry only one copy in inventory (e.g. Annihilus, Torch). */
carry1: boolean
/** Price multiplier (from `cost mult`). */
costMult: number
/** Flat price addition (from `cost add`). */
costAdd: number
/** Character color transform code, if any (e.g. 'dyel', 'dgrn'). */
chrtransform?: string
/** Inventory icon color transform code, if any. */
invtransform?: string
/** Custom inventory graphic file name, if any (e.g. 'invhaxu'). */
invfile?: string
/** Item modifiers (up to 12). */
props: UniqueItemProp[]
/** Optional base item type name (from `*type`). */
type?: string
/** Whether this is an uber quest item (from `*uber`). */
uber?: boolean
/** Flippy animation file (from `flippyfile`). */
flippyfile?: string
/** Sound played on drop (from `dropsound`). */
dropsound?: string
/** Sound played on use (from `usesound`). */
usesound?: string
}
/**
* Case-insensitive, whitespace-tolerant Map that preserves original key casing for iteration.
*/
export class CaseInsensitiveMap<V> extends Map<string, V> {
private readonly _keyMap = new Map<string, string>()
override get(key: string): V | undefined {
if (typeof key !== 'string') return undefined
const lower = key.trim().toLowerCase()
const original = this._keyMap.get(lower)
return original !== undefined ? super.get(original) : super.get(key)
}
override has(key: string): boolean {
if (typeof key !== 'string') return false
const lower = key.trim().toLowerCase()
return this._keyMap.has(lower) || super.has(key)
}
override set(key: string, value: V): this {
const lower = typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase()
const prevOriginal = this._keyMap.get(lower)
if (prevOriginal !== undefined && prevOriginal !== key) {
super.delete(prevOriginal)
}
this._keyMap.set(lower, key)
return super.set(key, value)
}
override delete(key: string): boolean {
const lower = typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase()
const original = this._keyMap.get(lower)
this._keyMap.delete(lower)
return super.delete(original ?? key)
}
override clear(): void {
this._keyMap.clear()
super.clear()
}
}
/**
* Parsed UniqueItems table providing list and indexed lookup access.
*/
export interface UniqueItemsTable {
/** All 401 parsed unique item records (including disabled ones). */
readonly all: UniqueItem[]
/** Map of unique items by index name (case-insensitive). */
readonly byIndex: Map<string, UniqueItem>
/** Map of unique items by base item code (case-insensitive). */
readonly byCode: Map<string, UniqueItem[]>
/**
* Returns all enabled unique items matching the specified base item code.
* Excludes disabled items (`enabled === false`).
*/
getEnabledByCode(code: string): UniqueItem[]
/** Total number of unique items parsed (401 in 1.13c). */
readonly length: number
/** Direct lookup by index name (case-insensitive). */
get(index: string): UniqueItem | undefined
/** Direct lookup by row id (0..400). */
getById(id: number): UniqueItem | undefined
/** Iterates over all items. */
[Symbol.iterator](): IterableIterator<UniqueItem>
}
/**
* Parses a parameter cell (par1..12).
*
* In Diablo II UniqueItems.txt, a parameter can be:
* - Empty string: returns undefined
* - Numeric string (e.g. "74", "-5", "0"): returns parsed number
* - Skill / stat name string (e.g. "Oak Sage", "Sanctuary", "Nova"): returns preserved string
*/
export function parsePar(val: string): string | number | undefined {
const trimmed = val.trim()
if (trimmed === '') return undefined
if (/^-?\d+$/.test(trimmed)) {
const num = Number(trimmed)
if (Number.isFinite(num)) return num
}
return trimmed
}
/**
* Parses a numeric cell with fallback.
*/
export function parseNumber(val: string, fallback = 0): number {
const trimmed = val.trim()
if (trimmed === '') return fallback
const num = Number(trimmed)
return Number.isFinite(num) ? num : fallback
}
/**
* Parses `UniqueItems.txt` from a raw tab-separated D2Table.
*
* @param table - Raw parsed tab-separated table.
* @returns Parsed `UniqueItemsTable`.
*/
export function parseUniqueItemsTable(table: D2Table): UniqueItemsTable {
const headerMap = new Map<string, number>()
table.header.forEach((name, idx) => {
headerMap.set(name.trim().toLowerCase(), idx)
})
const getCell = (row: readonly string[], ...names: string[]): string => {
for (const name of names) {
const idx = headerMap.get(name.toLowerCase())
if (idx !== undefined && idx < row.length) {
const val = (row[idx] ?? '').trim()
if (val !== '') return val
}
}
return ''
}
const all: UniqueItem[] = []
const byIndex = new CaseInsensitiveMap<UniqueItem>()
const byCode = new CaseInsensitiveMap<UniqueItem[]>()
for (const row of table.rows) {
const index = getCell(row, 'index')
// Filter out rows where index is empty or 'Expansion' separator
if (!index || index.toLowerCase() === 'expansion') {
continue
}
const id = all.length
const version = parseNumber(getCell(row, 'version'), 0)
const enabled = getCell(row, 'enabled') === '1'
const ladder = getCell(row, 'ladder') === '1'
const rarity = parseNumber(getCell(row, 'rarity'), 0)
const nolimit = getCell(row, 'nolimit') === '1'
const lvl = parseNumber(getCell(row, 'lvl'), 0)
const levelreq = parseNumber(getCell(row, 'lvl req', 'lvlreq', 'levelreq'), 0)
const code = getCell(row, 'code')
const carry1 = getCell(row, 'carry1') === '1'
const costMult = parseNumber(getCell(row, 'cost mult', 'costmult'), 0)
const costAdd = parseNumber(getCell(row, 'cost add', 'costadd'), 0)
const chrtransform = getCell(row, 'chrtransform')
const invtransform = getCell(row, 'invtransform')
const invfile = getCell(row, 'invfile')
const type = getCell(row, '*type', 'type')
const uber = getCell(row, '*uber', 'uber') === '1'
const flippyfile = getCell(row, 'flippyfile')
const dropsound = getCell(row, 'dropsound')
const usesound = getCell(row, 'usesound')
const props: UniqueItemProp[] = []
for (let slot = 1; slot <= 12; slot++) {
const propCode = getCell(row, `prop${slot}`)
if (!propCode) continue
const par = parsePar(getCell(row, `par${slot}`))
const min = parseNumber(getCell(row, `min${slot}`), 0)
const max = parseNumber(getCell(row, `max${slot}`), 0)
const prop: UniqueItemProp = {
code: propCode,
min,
max,
...(par !== undefined ? { par } : {}),
}
props.push(prop)
}
const item: UniqueItem = {
id,
index,
version,
enabled,
ladder,
rarity,
nolimit,
lvl,
levelreq,
code,
carry1,
costMult,
costAdd,
props,
...(chrtransform ? { chrtransform } : {}),
...(invtransform ? { invtransform } : {}),
...(invfile ? { invfile } : {}),
...(type ? { type } : {}),
...(uber ? { uber: true } : {}),
...(flippyfile ? { flippyfile } : {}),
...(dropsound ? { dropsound } : {}),
...(usesound ? { usesound } : {}),
}
all.push(item)
// Primary key lookup: enabled item takes precedence for duplicate indices like Azurewrath
const existing = byIndex.get(index)
if (!existing || (!existing.enabled && item.enabled)) {
byIndex.set(index, item)
}
// Base code multi-value index
if (code) {
let codeList = byCode.get(code)
if (!codeList) {
codeList = []
byCode.set(code, codeList)
}
codeList.push(item)
}
}
const getEnabledByCode = (itemCode: string): UniqueItem[] => {
const list = byCode.get(itemCode)
if (!list) return []
return list.filter(item => item.enabled)
}
return {
all,
byIndex,
byCode,
getEnabledByCode,
length: all.length,
get: (idx: string) => byIndex.get(idx),
getById: (id: number) => (id >= 0 && id < all.length ? all[id] : undefined),
[Symbol.iterator]() {
return all[Symbol.iterator]()
},
}
}
/**
* Loads and parses `UniqueItems.txt` from mounted MPQ archives.
*
* @param archives - Mounted MPQ archives.
* @param tablePath - Optional path inside archive (defaults to UNIQUE_ITEMS_TABLE_PATH).
* @returns Promise resolving to `UniqueItemsTable`.
*/
export async function loadUniqueItems(
archives: MountedArchives,
tablePath = UNIQUE_ITEMS_TABLE_PATH,
): Promise<UniqueItemsTable> {
const bytes = await archives.read(tablePath)
const d2Table = parseTable(bytes)
return parseUniqueItemsTable(d2Table)
}

345
tests/unique-items.test.ts Normal file
View File

@ -0,0 +1,345 @@
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 } from '../src/game/acts.ts'
import {
parseUniqueItemsTable,
loadUniqueItems,
parsePar,
parseNumber,
UNIQUE_ITEMS_TABLE_PATH,
CaseInsensitiveMap,
type UniqueItemsTable,
type UniqueItem,
} from '../src/game/unique-items.ts'
const hasD2 = fs.existsSync('samples/d2/d2exp.mpq')
describe('UniqueItems Table Loader (Issue #97)', () => {
describe('Helper functions: parsePar and parseNumber', () => {
it('parses empty and whitespace strings as undefined in parsePar', () => {
expect(parsePar('')).toBeUndefined()
expect(parsePar(' ')).toBeUndefined()
expect(parsePar('\t')).toBeUndefined()
})
it('parses numeric strings as numbers in parsePar', () => {
expect(parsePar('74')).toBe(74)
expect(parsePar('0')).toBe(0)
expect(parsePar('-5')).toBe(-5)
expect(parsePar('103')).toBe(103)
expect(typeof parsePar('74')).toBe('number')
})
it('preserves skill and property names as strings in parsePar', () => {
expect(parsePar('Oak Sage')).toBe('Oak Sage')
expect(parsePar('Sanctuary')).toBe('Sanctuary')
expect(parsePar('Nova')).toBe('Nova')
expect(parsePar('Enchant')).toBe('Enchant')
expect(typeof parsePar('Oak Sage')).toBe('string')
})
it('parses numbers with fallback in parseNumber', () => {
expect(parseNumber('')).toBe(0)
expect(parseNumber('', 42)).toBe(42)
expect(parseNumber('100')).toBe(100)
expect(parseNumber('-15')).toBe(-15)
expect(parseNumber('invalid', 99)).toBe(99)
})
})
describe('CaseInsensitiveMap', () => {
it('supports case-insensitive lookups while preserving original keys', () => {
const map = new CaseInsensitiveMap<number>()
map.set('The Gnasher', 1)
expect(map.get('The Gnasher')).toBe(1)
expect(map.get('the gnasher')).toBe(1)
expect(map.get('THE GNASHER')).toBe(1)
expect(map.has('the gnasher')).toBe(true)
expect(map.has('other')).toBe(false)
expect(Array.from(map.keys())).toEqual(['The Gnasher'])
})
it('handles delete and clear properly', () => {
const map = new CaseInsensitiveMap<string>()
map.set('rin', 'ring')
expect(map.has('RIN')).toBe(true)
expect(map.delete('Rin')).toBe(true)
expect(map.has('rin')).toBe(false)
map.set('amu', 'amulet')
map.clear()
expect(map.has('amu')).toBe(false)
})
})
describe('Synthetic parseUniqueItemsTable tests', () => {
const header = [
'index', 'version', 'enabled', 'ladder', 'rarity', 'nolimit', 'lvl', 'lvl req',
'code', '*type', '*uber', 'carry1', 'cost mult', 'cost add', 'chrtransform', 'invtransform',
'flippyfile', 'invfile', 'dropsound', 'dropsfxframe', 'usesound',
'prop1', 'par1', 'min1', 'max1',
'prop2', 'par2', 'min2', 'max2',
'*eol',
]
it('filters empty index and Expansion rows', () => {
const rawTable: D2Table = {
header,
rows: [
['The Gnasher', '0', '1', '', '1', '', '7', '5', 'hax', 'Hand Axe', '', '', '5', '5000', 'dyel', '', '', 'invhaxu', '', '', '', 'str', '', '8', '8', '', '', '', '', '0'],
['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0'],
['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0'],
['Nagelring', '0', '1', '', '15', '', '10', '7', 'rin', 'ring', '', '', '5', '5000', '', '', '', '', '', '', '', 'mag%', '', '15', '30', 'att', '', '50', '75', '0'],
['Constricting Ring', '100', '0', '', '1', '', '95', '95', 'rin', 'ring', '', '', '5', '5000', '', '', '', '', '', '', '', 'res-all', '', '100', '100', '', '', '', '', '0'],
],
}
const table = parseUniqueItemsTable(rawTable)
expect(table.all.length).toBe(3)
expect(table.all[0]?.index).toBe('The Gnasher')
expect(table.all[0]?.id).toBe(0)
expect(table.all[1]?.index).toBe('Nagelring')
expect(table.all[1]?.id).toBe(1)
expect(table.all[2]?.index).toBe('Constricting Ring')
expect(table.all[2]?.id).toBe(2)
expect(table.all[2]?.enabled).toBe(false)
const rings = table.byCode.get('rin')
expect(rings?.length).toBe(2)
const enabledRings = table.getEnabledByCode('rin')
expect(enabledRings.length).toBe(1)
expect(enabledRings[0]?.index).toBe('Nagelring')
})
})
describe.skipIf(!hasD2)('1.13c MPQ Integration Tests (UniqueItems.txt)', () => {
let archives: MountedArchives
let rawTable: D2Table
let table: UniqueItemsTable
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}`)))
}
const bytes = await archives.read(UNIQUE_ITEMS_TABLE_PATH)
rawTable = parseTable(bytes)
table = parseUniqueItemsTable(rawTable)
})
it('verifies archive path constant', () => {
expect(UNIQUE_ITEMS_TABLE_PATH).toBe('data\\global\\excel\\UniqueItems.txt')
})
it('loads via loadUniqueItems with mounted archives', async () => {
const loaded = await loadUniqueItems(archives)
expect(loaded.all.length).toBe(401)
expect(loaded.length).toBe(401)
expect(loaded.byIndex.size).toBe(393)
})
it('parses exactly 401 real data rows from 403 raw lines', () => {
expect(rawTable.rows.length).toBe(403)
expect(table.all.length).toBe(401)
expect(table.length).toBe(401)
// Verifies row id 0..400
for (let i = 0; i < table.all.length; i++) {
expect(table.all[i]?.id).toBe(i)
expect(table.getById(i)?.id).toBe(i)
}
expect(table.all[0]?.index).toBe('The Gnasher')
expect(table.all[400]?.index).toBe('Hellfire Torch')
})
it('verifies exactly 385 enabled and 16 disabled unique items', () => {
const enabledItems = table.all.filter(i => i.enabled)
const disabledItems = table.all.filter(i => !i.enabled)
expect(enabledItems.length).toBe(385)
expect(disabledItems.length).toBe(16)
const disabledIndices = disabledItems.map(i => i.index)
expect(disabledIndices).toContain('Armor')
expect(disabledIndices).toContain('Elite Uniques')
expect(disabledIndices).toContain('Constricting Ring')
expect(disabledIndices).toContain('Rings')
expect(disabledIndices).toContain('Class Specific')
expect(disabledIndices).toContain('Gore Ripper')
expect(disabledIndices).toContain("Zakarum's Salvation")
expect(disabledIndices).toContain('Odium')
expect(disabledIndices).toContain("Larzuk's Champion")
expect(disabledIndices).toContain('Giantmaimer')
expect(disabledIndices).toContain('Darkfear')
expect(disabledIndices).toContain('Nethercrow')
expect(disabledIndices).toContain("Warriv's Warder")
expect(disabledIndices).toContain("Merman's Speed")
expect(disabledIndices).toContain("Sigurd's Staunch")
// Classic Azurewrath (id 29) is disabled
expect(table.all[29]?.index).toBe('Azurewrath')
expect(table.all[29]?.enabled).toBe(false)
expect(table.all[29]?.code).toBe('crs')
})
it('sample check: The Gnasher (code=hax, lvl=7, 4 props: str, openwounds, crush, dmg%)', () => {
const gnasher = table.byIndex.get('The Gnasher')
expect(gnasher).toBeDefined()
expect(gnasher?.id).toBe(0)
expect(gnasher?.index).toBe('The Gnasher')
expect(gnasher?.code).toBe('hax')
expect(gnasher?.lvl).toBe(7)
expect(gnasher?.levelreq).toBe(5)
expect(gnasher?.version).toBe(0)
expect(gnasher?.enabled).toBe(true)
expect(gnasher?.ladder).toBe(false)
expect(gnasher?.rarity).toBe(1)
expect(gnasher?.nolimit).toBe(false)
expect(gnasher?.carry1).toBe(false)
expect(gnasher?.costMult).toBe(5)
expect(gnasher?.costAdd).toBe(5000)
expect(gnasher?.chrtransform).toBe('dyel')
expect(gnasher?.invfile).toBe('invhaxu')
expect(gnasher?.invtransform).toBeUndefined()
expect(gnasher?.props.length).toBe(4)
expect(gnasher?.props[0]).toEqual({ code: 'str', min: 8, max: 8 })
expect(gnasher?.props[1]).toEqual({ code: 'openwounds', min: 50, max: 50 })
expect(gnasher?.props[2]).toEqual({ code: 'crush', min: 20, max: 20 })
expect(gnasher?.props[3]).toEqual({ code: 'dmg%', min: 60, max: 70 })
})
it('verifies string parameter par: Nature\'s Peace with Oak Sage', () => {
const peace = table.byIndex.get("Nature's Peace")
expect(peace).toBeDefined()
expect(peace?.code).toBe('rin')
expect(peace?.lvl).toBe(77)
expect(peace?.levelreq).toBe(69)
expect(peace?.ladder).toBe(true)
expect(peace?.version).toBe(100)
expect(peace?.enabled).toBe(true)
expect(peace?.chrtransform).toBe('dgrn')
expect(peace?.invtransform).toBe('dgrn')
const chargedProp = peace?.props.find(p => p.code === 'charged')
expect(chargedProp).toBeDefined()
expect(chargedProp?.par).toBe('Oak Sage')
expect(typeof chargedProp?.par).toBe('string')
expect(chargedProp?.min).toBe(27)
expect(chargedProp?.max).toBe(5)
// Other properties on Nature's Peace
expect(peace?.props.find(p => p.code === 'noheal')).toEqual({ code: 'noheal', min: 1, max: 1 })
expect(peace?.props.find(p => p.code === 'rip')).toEqual({ code: 'rip', min: 1, max: 1 })
expect(peace?.props.find(p => p.code === 'red-dmg')).toEqual({ code: 'red-dmg', min: 7, max: 11 })
expect(peace?.props.find(p => p.code === 'res-pois')).toEqual({ code: 'res-pois', min: 20, max: 30 })
})
it('verifies numeric parameter par in items like Maelstromwrath', () => {
const maelstrom = table.byIndex.get('Maelstromwrath')
expect(maelstrom).toBeDefined()
expect(maelstrom?.code).toBe('ywn')
const skillProps = maelstrom?.props.filter(p => p.code === 'skill')
expect(skillProps?.length).toBe(4)
expect(skillProps?.[0]?.par).toBe(74)
expect(typeof skillProps?.[0]?.par).toBe('number')
expect(skillProps?.[1]?.par).toBe(77)
expect(skillProps?.[2]?.par).toBe(66)
expect(skillProps?.[3]?.par).toBe(76)
})
it('verifies multi-value index code -> UniqueItem[] for rings and amulets', () => {
// Rings
const rings = table.byCode.get('rin')
expect(rings).toBeDefined()
expect(rings?.length).toBe(10)
const ringNames = rings?.map(r => r.index)
expect(ringNames).toEqual([
'Nagelring',
'Manald Heal',
'The Stone of Jordan',
'Constricting Ring',
"Bul Katho's Wedding Band",
'Dwarf Star',
'Raven Frost',
"Nature's Peace",
'Wisp',
'Carrion Wind',
])
// Exactly 9 enabled rings (Constricting Ring is disabled)
const enabledRings = table.getEnabledByCode('rin')
expect(enabledRings.length).toBe(9)
expect(enabledRings.some(r => r.index === 'Constricting Ring')).toBe(false)
expect(enabledRings.every(r => r.enabled)).toBe(true)
// Amulets: exactly 12 unique amulets, all enabled
const amulets = table.byCode.get('amu')
expect(amulets).toBeDefined()
expect(amulets?.length).toBe(12)
const enabledAmulets = table.getEnabledByCode('amu')
expect(enabledAmulets.length).toBe(12)
// Rainbow Facets (jew)
const facets = table.byCode.get('jew')
expect(facets?.length).toBe(8)
expect(table.getEnabledByCode('jew').length).toBe(8)
// Hand axe (hax)
const hax = table.byCode.get('hax')
expect(hax?.length).toBe(1)
expect(hax?.[0]?.index).toBe('The Gnasher')
// Case-insensitivity
expect(table.byCode.get('RIN')?.length).toBe(10)
expect(table.getEnabledByCode('RIN').length).toBe(9)
expect(table.getEnabledByCode('unknown_code')).toEqual([])
})
it('verifies primary key lookup and duplicate resolution', () => {
// Direct get and byIndex.get
expect(table.get('The Gnasher')?.index).toBe('The Gnasher')
expect(table.byIndex.get('the gnasher')?.index).toBe('The Gnasher')
expect(table.byIndex.get('THE GNASHER')?.index).toBe('The Gnasher')
// Azurewrath duplicate: enabled Phase Blade (id 301) takes precedence over disabled Crystal Sword (id 29)
const azurewrath = table.byIndex.get('Azurewrath')
expect(azurewrath).toBeDefined()
expect(azurewrath?.enabled).toBe(true)
expect(azurewrath?.code).toBe('7cr')
expect(azurewrath?.lvl).toBe(87)
expect(azurewrath?.id).toBe(301)
})
it('verifies carry1 items (Annihilus, Gheed\'s Fortune, Hellfire Torch)', () => {
const carry1Items = table.all.filter(i => i.carry1)
expect(carry1Items.length).toBe(3)
const carry1Names = carry1Items.map(i => i.index).sort()
expect(carry1Names).toEqual(['Annihilus', "Gheed's Fortune", 'Hellfire Torch'].sort())
})
it('verifies negative stats such as Gull dagger penalties', () => {
const gull = table.byIndex.get('Gull')
expect(gull).toBeDefined()
expect(gull?.code).toBe('dgr')
const manaProp = gull?.props.find(p => p.code === 'mana')
expect(manaProp).toEqual({ code: 'mana', min: -5, max: -5 })
const mfProp = gull?.props.find(p => p.code === 'mag%')
expect(mfProp).toEqual({ code: 'mag%', min: 100, max: 100 })
})
it('supports iterable for..of loop across all items', () => {
let count = 0
for (const item of table) {
expect(item.index).toBeTruthy()
expect(item.id).toBe(count)
count++
}
expect(count).toBe(401)
})
})
})