feat(items): 加载 Misc.txt (Issue #93)
This commit is contained in:
parent
7187154ac8
commit
050cb25dfe
|
|
@ -0,0 +1,275 @@
|
||||||
|
/**
|
||||||
|
* Diablo II miscellaneous items loader (`Misc.txt`).
|
||||||
|
*
|
||||||
|
* `Misc.txt` defines all non-weapon, non-armor items: potions, scrolls, runes,
|
||||||
|
* charms, gems, jewels, gold, keys, quest items, and books.
|
||||||
|
*
|
||||||
|
* In vanilla Diablo II 1.13c:
|
||||||
|
* - 152 raw rows - 1 'Expansion' separator row = exactly 151 real data rows.
|
||||||
|
* - 98 of 151 items are spawnable (`spawnable === true`).
|
||||||
|
* - 33 runes `r01`..`r33` with sequential rune numbers 1..33.
|
||||||
|
* - `gld` is gold (`isGold === true`, `type === 'gold'`).
|
||||||
|
*/
|
||||||
|
import type { D2Table } from './acts.ts'
|
||||||
|
import { cell, parseTable } from './acts.ts'
|
||||||
|
import type { ItemBase } from './items.ts'
|
||||||
|
import type { MountedArchives } from '../mpq/mount.ts'
|
||||||
|
|
||||||
|
/** File path of `Misc.txt` inside MPQ archives. */
|
||||||
|
export const MISC_TABLE_PATH = 'data\\global\\excel\\Misc.txt'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definition of a miscellaneous item base from `Misc.txt`, conforming to `ItemBase`.
|
||||||
|
*/
|
||||||
|
export interface MiscBase extends ItemBase {
|
||||||
|
/** Item code (e.g. 'hp1', 'rin', 'r01'). Same as `id`. */
|
||||||
|
readonly id: string
|
||||||
|
/** Item code (3-4 characters). */
|
||||||
|
readonly code: string
|
||||||
|
/** Display name from the `name` column. */
|
||||||
|
readonly name: string
|
||||||
|
/** String table lookup key from `namestr`. */
|
||||||
|
readonly namestr: string
|
||||||
|
/** Game version flag (0 = classic, 100 = expansion). */
|
||||||
|
readonly version: number
|
||||||
|
/** Broad item kind, always 'misc'. */
|
||||||
|
readonly kind: 'misc'
|
||||||
|
/** Whether the item can drop or spawn randomly (1 or 0 in table). */
|
||||||
|
readonly spawnable: boolean
|
||||||
|
/** Drop rarity weighting (0..999). */
|
||||||
|
readonly rarity: number
|
||||||
|
/** Item level (0..90). */
|
||||||
|
readonly level: number
|
||||||
|
/** Primary item type code (e.g. 'hpot', 'ring', 'rune'). */
|
||||||
|
readonly type: string
|
||||||
|
/** Secondary item type code if defined (e.g. 'gem0'..'gem4'). */
|
||||||
|
readonly type2?: string
|
||||||
|
/** Base gold purchase cost. */
|
||||||
|
readonly cost: number
|
||||||
|
/** Cost to purchase in gambling window (0 if not gambleable). */
|
||||||
|
readonly gambleCost: number
|
||||||
|
/** Inventory grid width in cells. */
|
||||||
|
readonly invWidth: number
|
||||||
|
/** Inventory grid height in cells. */
|
||||||
|
readonly invHeight: number
|
||||||
|
/** Whether items of this base can stack in inventory. */
|
||||||
|
readonly stackable: boolean
|
||||||
|
/** Minimum spawned stack quantity if stackable. */
|
||||||
|
readonly minstack?: number
|
||||||
|
/** Maximum stack quantity if stackable. */
|
||||||
|
readonly maxstack?: number
|
||||||
|
/** Default stack quantity when spawned if stackable. */
|
||||||
|
readonly spawnstack?: number
|
||||||
|
/** Whether the item automatically slots into the potion belt on pickup. */
|
||||||
|
readonly autobelt: boolean
|
||||||
|
/** Hardcoded spell/effect index parameter if usable. */
|
||||||
|
readonly pSpell?: number
|
||||||
|
/** Whether the item can be transmogrified in the Horadric Cube. */
|
||||||
|
readonly transmogrify: boolean
|
||||||
|
/** Item tags for affix matching ([type, type2]). */
|
||||||
|
readonly tags: string[]
|
||||||
|
/** Whether this base represents gold (`code === 'gld'`). */
|
||||||
|
readonly isGold: boolean
|
||||||
|
/** Whether this base is one of the 33 runes (`r01`..`r33`). */
|
||||||
|
readonly isRune: boolean
|
||||||
|
/** Sequential rune number (1..33) if this base is a rune. */
|
||||||
|
readonly runeNumber?: number
|
||||||
|
|
||||||
|
// ItemBase conformance
|
||||||
|
/** How many fit in one cell (from maxstack for stackable items, else 1). */
|
||||||
|
readonly maxStack: number
|
||||||
|
/** Gold value (from cost). */
|
||||||
|
readonly value: number
|
||||||
|
/** Weapon damage (0 for misc items). */
|
||||||
|
readonly damage: number
|
||||||
|
/** Armor rating (0 for misc items). */
|
||||||
|
readonly defense: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive, whitespace-tolerant Map for looking up items 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 miscellaneous items table providing collection and indexed lookup access.
|
||||||
|
*/
|
||||||
|
export interface MiscTable {
|
||||||
|
/** Map of misc item bases keyed by code (case-insensitive and trimmed). */
|
||||||
|
readonly byCode: Map<string, MiscBase>
|
||||||
|
/** All valid misc item bases in file order (151 items in vanilla 1.13c). */
|
||||||
|
readonly all: MiscBase[]
|
||||||
|
/** Look up an item base by code. */
|
||||||
|
get(code: string): MiscBase | undefined
|
||||||
|
/** Look up a rune by its number (1..33 for r01..r33). */
|
||||||
|
getRune(num: number): MiscBase | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `Misc.txt` from a raw tab-separated D2Table (or Uint8Array/string).
|
||||||
|
*
|
||||||
|
* Filters out:
|
||||||
|
* - The `Expansion` separator row
|
||||||
|
* - Any rows with empty `code`
|
||||||
|
*
|
||||||
|
* In vanilla Diablo II 1.13c: 152 raw rows - 1 'Expansion' row = 151 real data rows.
|
||||||
|
*
|
||||||
|
* @param input - Raw D2Table, Uint8Array bytes, or TSV string.
|
||||||
|
* @returns Parsed `MiscTable` with list, code lookup, and rune indexing.
|
||||||
|
*/
|
||||||
|
export function parseMiscTable(table: D2Table): MiscTable
|
||||||
|
export function parseMiscTable(bytes: Uint8Array): MiscTable
|
||||||
|
export function parseMiscTable(tsv: string): MiscTable
|
||||||
|
export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable {
|
||||||
|
let table: D2Table
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
table = parseTable(new TextEncoder().encode(input))
|
||||||
|
} else if (input instanceof Uint8Array) {
|
||||||
|
table = parseTable(input)
|
||||||
|
} else {
|
||||||
|
table = input
|
||||||
|
}
|
||||||
|
|
||||||
|
const all: MiscBase[] = []
|
||||||
|
const byCode = new CaseInsensitiveMap<MiscBase>()
|
||||||
|
const runesByNumber = new Map<number, MiscBase>()
|
||||||
|
|
||||||
|
for (const row of table.rows) {
|
||||||
|
const code = cell(table, row, 'code').trim()
|
||||||
|
const name = cell(table, row, 'name').trim()
|
||||||
|
|
||||||
|
// 152 raw lines - 1 'Expansion' row = 151 real data rows.
|
||||||
|
if (!code || name === 'Expansion') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const namestr = cell(table, row, 'namestr').trim()
|
||||||
|
const version = Number(cell(table, row, 'version')) || 0
|
||||||
|
const spawnable = cell(table, row, 'spawnable').trim() === '1'
|
||||||
|
const rarity = Number(cell(table, row, 'rarity')) || 0
|
||||||
|
const level = Number(cell(table, row, 'level')) || 0
|
||||||
|
const type = cell(table, row, 'type').trim()
|
||||||
|
const type2Raw = cell(table, row, 'type2').trim()
|
||||||
|
const type2 = type2Raw !== '' ? type2Raw : undefined
|
||||||
|
const cost = Number(cell(table, row, 'cost')) || 0
|
||||||
|
const gambleCost = Number(cell(table, row, 'gamble cost')) || 0
|
||||||
|
const invWidth = Number(cell(table, row, 'invwidth')) || 0
|
||||||
|
const invHeight = Number(cell(table, row, 'invheight')) || 0
|
||||||
|
const stackable = cell(table, row, 'stackable').trim() === '1'
|
||||||
|
|
||||||
|
const minstackRaw = cell(table, row, 'minstack').trim()
|
||||||
|
const maxstackRaw = cell(table, row, 'maxstack').trim()
|
||||||
|
const spawnstackRaw = cell(table, row, 'spawnstack').trim()
|
||||||
|
|
||||||
|
const minstack = stackable && minstackRaw !== '' ? Number(minstackRaw) : undefined
|
||||||
|
const maxstack = stackable && maxstackRaw !== '' ? Number(maxstackRaw) : undefined
|
||||||
|
const spawnstack = stackable && spawnstackRaw !== '' ? Number(spawnstackRaw) : undefined
|
||||||
|
|
||||||
|
const autobelt = cell(table, row, 'autobelt').trim() === '1'
|
||||||
|
|
||||||
|
const pSpellRaw = (cell(table, row, 'pSpell') || cell(table, row, 'pspell')).trim()
|
||||||
|
const pSpell = pSpellRaw !== '' ? Number(pSpellRaw) : undefined
|
||||||
|
|
||||||
|
const transmogrify =
|
||||||
|
cell(table, row, 'Transmogrify').trim() === '1' ||
|
||||||
|
cell(table, row, 'transmogrify').trim() === '1'
|
||||||
|
|
||||||
|
const tags: string[] = type2 !== undefined ? [type, type2] : [type]
|
||||||
|
|
||||||
|
const isGold = code.toLowerCase() === 'gld'
|
||||||
|
|
||||||
|
const runeSuffix = code.slice(1)
|
||||||
|
const runeNum = Number(runeSuffix)
|
||||||
|
const isRune =
|
||||||
|
code.toLowerCase().startsWith('r') &&
|
||||||
|
!isNaN(runeNum) &&
|
||||||
|
runeNum >= 1 &&
|
||||||
|
runeNum <= 33
|
||||||
|
|
||||||
|
const runeNumber = isRune ? runeNum : undefined
|
||||||
|
|
||||||
|
const maxStack = stackable && maxstack !== undefined ? maxstack : 1
|
||||||
|
const value = cost
|
||||||
|
const damage = 0
|
||||||
|
const defense = 0
|
||||||
|
|
||||||
|
const item: MiscBase = {
|
||||||
|
id: code,
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
namestr,
|
||||||
|
version,
|
||||||
|
kind: 'misc',
|
||||||
|
spawnable,
|
||||||
|
rarity,
|
||||||
|
level,
|
||||||
|
type,
|
||||||
|
...(type2 !== undefined ? { type2 } : {}),
|
||||||
|
cost,
|
||||||
|
gambleCost,
|
||||||
|
invWidth,
|
||||||
|
invHeight,
|
||||||
|
stackable,
|
||||||
|
...(minstack !== undefined ? { minstack } : {}),
|
||||||
|
...(maxstack !== undefined ? { maxstack } : {}),
|
||||||
|
...(spawnstack !== undefined ? { spawnstack } : {}),
|
||||||
|
autobelt,
|
||||||
|
...(pSpell !== undefined ? { pSpell } : {}),
|
||||||
|
transmogrify,
|
||||||
|
tags,
|
||||||
|
isGold,
|
||||||
|
isRune,
|
||||||
|
...(runeNumber !== undefined ? { runeNumber } : {}),
|
||||||
|
|
||||||
|
maxStack,
|
||||||
|
value,
|
||||||
|
damage,
|
||||||
|
defense,
|
||||||
|
}
|
||||||
|
|
||||||
|
all.push(item)
|
||||||
|
byCode.set(code, item)
|
||||||
|
if (isRune && runeNumber !== undefined) {
|
||||||
|
runesByNumber.set(runeNumber, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
byCode,
|
||||||
|
all,
|
||||||
|
get: (c: string) => byCode.get(c),
|
||||||
|
getRune: (num: number) => runesByNumber.get(num),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and parses `Misc.txt` from mounted MPQ archives.
|
||||||
|
*
|
||||||
|
* @param archives - Mounted MPQ archives.
|
||||||
|
* @returns Promise resolving to `MiscTable`.
|
||||||
|
*/
|
||||||
|
export async function loadMisc(archives: MountedArchives): Promise<MiscTable> {
|
||||||
|
const bytes = await archives.read(MISC_TABLE_PATH)
|
||||||
|
const d2Table = parseTable(bytes)
|
||||||
|
return parseMiscTable(d2Table)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,462 @@
|
||||||
|
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 {
|
||||||
|
parseMiscTable,
|
||||||
|
loadMisc,
|
||||||
|
MISC_TABLE_PATH,
|
||||||
|
type MiscTable,
|
||||||
|
type MiscBase,
|
||||||
|
} from '../src/game/misc-items.ts'
|
||||||
|
|
||||||
|
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||||
|
|
||||||
|
describe('Misc.txt loader & MiscBase mapping (Issue #93)', () => {
|
||||||
|
let table: MiscTable
|
||||||
|
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(MISC_TABLE_PATH)
|
||||||
|
rawTable = parseTable(bytes)
|
||||||
|
table = parseMiscTable(rawTable)
|
||||||
|
} else {
|
||||||
|
rawTable = createMinimalFallbackTable()
|
||||||
|
table = parseMiscTable(rawTable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the canonical MPQ table path data\\global\\excel\\Misc.txt', () => {
|
||||||
|
expect(MISC_TABLE_PATH).toBe('data\\global\\excel\\Misc.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads misc items via loadMisc 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 loadMisc(archives)
|
||||||
|
expect(loaded.all.length).toBe(151)
|
||||||
|
expect(loaded.byCode.size).toBe(151)
|
||||||
|
expect(loaded.get('hp1')?.name).toBe('Lesser Healing Potion')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies 152 raw rows in original file - 1 Expansion row = 151 real data rows with 0 duplicate codes', () => {
|
||||||
|
if (hasD2 && rawTable) {
|
||||||
|
expect(rawTable.rows.length).toBe(152)
|
||||||
|
|
||||||
|
// Verify Expansion row at index 94
|
||||||
|
const expRow = rawTable.rows[94]!
|
||||||
|
expect(cell(rawTable, expRow, 'name')).toBe('Expansion')
|
||||||
|
expect(cell(rawTable, expRow, 'code')).toBe('')
|
||||||
|
|
||||||
|
expect(table.all.length).toBe(151)
|
||||||
|
expect(table.byCode.size).toBe(151)
|
||||||
|
|
||||||
|
// Verify 0 duplicate codes
|
||||||
|
const seenCodes = new Set<string>()
|
||||||
|
for (const item of table.all) {
|
||||||
|
expect(seenCodes.has(item.code)).toBe(false)
|
||||||
|
seenCodes.add(item.code)
|
||||||
|
}
|
||||||
|
expect(seenCodes.size).toBe(151)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Expansion row is excluded
|
||||||
|
expect(table.byCode.has('Expansion')).toBe(false)
|
||||||
|
expect(table.byCode.has('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads ALL 151 rows including exactly 98 spawnable rows', () => {
|
||||||
|
if (hasD2) {
|
||||||
|
const spawnableCount = table.all.filter(i => i.spawnable).length
|
||||||
|
expect(spawnableCount).toBe(98)
|
||||||
|
|
||||||
|
const nonSpawnableCount = table.all.filter(i => !i.spawnable).length
|
||||||
|
expect(nonSpawnableCount).toBe(53)
|
||||||
|
expect(spawnableCount + nonSpawnableCount).toBe(151)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies gld is recognized as gold (isGold === true, type === "gold")', () => {
|
||||||
|
const gold = table.get('gld')
|
||||||
|
expect(gold).toBeDefined()
|
||||||
|
expect(gold?.isGold).toBe(true)
|
||||||
|
expect(gold?.type).toBe('gold')
|
||||||
|
expect(gold?.name).toBe('gold')
|
||||||
|
expect(gold?.stackable).toBe(true)
|
||||||
|
expect(gold?.maxstack).toBe(5000)
|
||||||
|
|
||||||
|
// All other items must not be gold
|
||||||
|
const nonGold = table.all.filter(i => i.code !== 'gld')
|
||||||
|
for (const item of nonGold) {
|
||||||
|
expect(item.isGold).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies 33 runes r01..r33 are accessible by number 1..33 and have isRune === true', () => {
|
||||||
|
if (hasD2) {
|
||||||
|
const runes = table.all.filter(i => i.isRune)
|
||||||
|
expect(runes.length).toBe(33)
|
||||||
|
|
||||||
|
for (let num = 1; num <= 33; num++) {
|
||||||
|
const rune = table.getRune(num)
|
||||||
|
expect(rune).toBeDefined()
|
||||||
|
expect(rune?.isRune).toBe(true)
|
||||||
|
expect(rune?.runeNumber).toBe(num)
|
||||||
|
|
||||||
|
const expectedCode = `r${String(num).padStart(2, '0')}`
|
||||||
|
expect(rune?.code).toBe(expectedCode)
|
||||||
|
expect(rune?.type).toBe('rune')
|
||||||
|
expect(rune?.tags).toEqual(['rune'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check specific first and last rune details
|
||||||
|
const elRune = table.getRune(1)!
|
||||||
|
expect(elRune.name).toBe('El Rune')
|
||||||
|
expect(elRune.level).toBe(11)
|
||||||
|
expect(elRune.cost).toBe(560)
|
||||||
|
|
||||||
|
const zodRune = table.getRune(33)!
|
||||||
|
expect(zodRune.name).toBe('Zod Rune')
|
||||||
|
expect(zodRune.level).toBe(69)
|
||||||
|
expect(zodRune.cost).toBe(134540)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundary check for getRune
|
||||||
|
expect(table.getRune(0)).toBeUndefined()
|
||||||
|
expect(table.getRune(34)).toBeUndefined()
|
||||||
|
expect(table.getRune(-1)).toBeUndefined()
|
||||||
|
|
||||||
|
// Non-rune items starting with 'r' (e.g. rin, rvs, rvl, rps, rpl)
|
||||||
|
for (const nonRuneCode of ['rin', 'rvs', 'rvl', 'rps', 'rpl']) {
|
||||||
|
const item = table.get(nonRuneCode)
|
||||||
|
if (item) {
|
||||||
|
expect(item.isRune).toBe(false)
|
||||||
|
expect(item.runeNumber).toBeUndefined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sample item checks (hp1, rin, cm1)', () => {
|
||||||
|
it('accurately parses Lesser Healing Potion (hp1)', () => {
|
||||||
|
const hp1 = table.get('hp1')!
|
||||||
|
expect(hp1).toBeDefined()
|
||||||
|
expect(hp1.id).toBe('hp1')
|
||||||
|
expect(hp1.code).toBe('hp1')
|
||||||
|
expect(hp1.name).toBe('Lesser Healing Potion')
|
||||||
|
expect(hp1.namestr).toBe('hp1')
|
||||||
|
expect(hp1.version).toBe(0)
|
||||||
|
expect(hp1.kind).toBe('misc')
|
||||||
|
expect(hp1.spawnable).toBe(true)
|
||||||
|
expect(hp1.rarity).toBe(1)
|
||||||
|
expect(hp1.level).toBe(0)
|
||||||
|
expect(hp1.type).toBe('hpot')
|
||||||
|
expect(hp1.type2).toBeUndefined()
|
||||||
|
expect(hp1.cost).toBe(30)
|
||||||
|
expect(hp1.gambleCost).toBe(0)
|
||||||
|
expect(hp1.invWidth).toBe(1)
|
||||||
|
expect(hp1.invHeight).toBe(1)
|
||||||
|
expect(hp1.stackable).toBe(false)
|
||||||
|
expect(hp1.minstack).toBeUndefined()
|
||||||
|
expect(hp1.maxstack).toBeUndefined()
|
||||||
|
expect(hp1.spawnstack).toBeUndefined()
|
||||||
|
expect(hp1.autobelt).toBe(true)
|
||||||
|
expect(hp1.pSpell).toBe(3)
|
||||||
|
expect(hp1.transmogrify).toBe(false)
|
||||||
|
expect(hp1.tags).toEqual(['hpot'])
|
||||||
|
expect(hp1.isGold).toBe(false)
|
||||||
|
expect(hp1.isRune).toBe(false)
|
||||||
|
expect(hp1.runeNumber).toBeUndefined()
|
||||||
|
|
||||||
|
// ItemBase conformance
|
||||||
|
expect(hp1.maxStack).toBe(1)
|
||||||
|
expect(hp1.value).toBe(30)
|
||||||
|
expect(hp1.damage).toBe(0)
|
||||||
|
expect(hp1.defense).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accurately parses Ring (rin)', () => {
|
||||||
|
const rin = table.get('rin')!
|
||||||
|
expect(rin).toBeDefined()
|
||||||
|
expect(rin.id).toBe('rin')
|
||||||
|
expect(rin.code).toBe('rin')
|
||||||
|
expect(rin.name).toBe('ring')
|
||||||
|
expect(rin.namestr).toBe('rin')
|
||||||
|
expect(rin.version).toBe(0)
|
||||||
|
expect(rin.kind).toBe('misc')
|
||||||
|
expect(rin.spawnable).toBe(true)
|
||||||
|
expect(rin.rarity).toBe(4)
|
||||||
|
expect(rin.level).toBe(1)
|
||||||
|
expect(rin.type).toBe('ring')
|
||||||
|
expect(rin.type2).toBeUndefined()
|
||||||
|
expect(rin.cost).toBe(1800)
|
||||||
|
expect(rin.gambleCost).toBe(50000)
|
||||||
|
expect(rin.invWidth).toBe(1)
|
||||||
|
expect(rin.invHeight).toBe(1)
|
||||||
|
expect(rin.stackable).toBe(false)
|
||||||
|
expect(rin.autobelt).toBe(false)
|
||||||
|
expect(rin.pSpell).toBeUndefined()
|
||||||
|
expect(rin.transmogrify).toBe(false)
|
||||||
|
expect(rin.tags).toEqual(['ring'])
|
||||||
|
expect(rin.isGold).toBe(false)
|
||||||
|
expect(rin.isRune).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accurately parses Small Charm (cm1)', () => {
|
||||||
|
const cm1 = table.get('cm1')!
|
||||||
|
expect(cm1).toBeDefined()
|
||||||
|
expect(cm1.id).toBe('cm1')
|
||||||
|
expect(cm1.code).toBe('cm1')
|
||||||
|
expect(cm1.name).toBe('Charm Small')
|
||||||
|
expect(cm1.namestr).toBe('cm1')
|
||||||
|
expect(cm1.version).toBe(100)
|
||||||
|
expect(cm1.kind).toBe('misc')
|
||||||
|
expect(cm1.spawnable).toBe(true)
|
||||||
|
expect(cm1.rarity).toBe(4)
|
||||||
|
expect(cm1.level).toBe(28)
|
||||||
|
expect(cm1.type).toBe('scha')
|
||||||
|
expect(cm1.type2).toBeUndefined()
|
||||||
|
expect(cm1.cost).toBe(2000)
|
||||||
|
expect(cm1.gambleCost).toBe(45000)
|
||||||
|
expect(cm1.invWidth).toBe(1)
|
||||||
|
expect(cm1.invHeight).toBe(1)
|
||||||
|
expect(cm1.stackable).toBe(false)
|
||||||
|
expect(cm1.autobelt).toBe(false)
|
||||||
|
expect(cm1.pSpell).toBeUndefined()
|
||||||
|
expect(cm1.transmogrify).toBe(false)
|
||||||
|
expect(cm1.tags).toEqual(['scha'])
|
||||||
|
expect(cm1.isGold).toBe(false)
|
||||||
|
expect(cm1.isRune).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Caution item categories', () => {
|
||||||
|
it('potions (hp1..hp5, mp1..mp5, rvs, rvl, vps)', () => {
|
||||||
|
for (const code of ['hp1', 'hp2', 'hp3', 'hp4', 'hp5']) {
|
||||||
|
const item = table.get(code)
|
||||||
|
if (item) {
|
||||||
|
expect(item.type).toBe('hpot')
|
||||||
|
expect(item.autobelt).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const code of ['mp1', 'mp2', 'mp3', 'mp4', 'mp5']) {
|
||||||
|
const item = table.get(code)
|
||||||
|
if (item) {
|
||||||
|
expect(item.type).toBe('mpot')
|
||||||
|
expect(item.autobelt).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const code of ['rvs', 'rvl']) {
|
||||||
|
const item = table.get(code)
|
||||||
|
if (item) {
|
||||||
|
expect(item.type).toBe('rpot')
|
||||||
|
expect(item.autobelt).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vps = table.get('vps')
|
||||||
|
if (vps) {
|
||||||
|
expect(vps.type).toBe('spot')
|
||||||
|
expect(vps.autobelt).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scrolls (tsc, isc)', () => {
|
||||||
|
const tsc = table.get('tsc')
|
||||||
|
if (tsc) {
|
||||||
|
expect(tsc.name).toBe('Town Portal Scroll')
|
||||||
|
expect(tsc.type).toBe('scro')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isc = table.get('isc')
|
||||||
|
if (isc) {
|
||||||
|
expect(isc.name).toBe('Identify Scroll')
|
||||||
|
expect(isc.type).toBe('scro')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charms (cm1..cm3)', () => {
|
||||||
|
const cm1 = table.get('cm1')
|
||||||
|
const cm2 = table.get('cm2')
|
||||||
|
const cm3 = table.get('cm3')
|
||||||
|
if (cm1) expect(cm1.type).toBe('scha')
|
||||||
|
if (cm2) expect(cm2.type).toBe('mcha')
|
||||||
|
if (cm3) expect(cm3.type).toBe('lcha')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('jewels (jew)', () => {
|
||||||
|
const jew = table.get('jew')
|
||||||
|
if (jew) {
|
||||||
|
expect(jew.name).toBe('Jewel')
|
||||||
|
expect(jew.type).toBe('jewl')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rings and amulets (rin, amu)', () => {
|
||||||
|
const rin = table.get('rin')
|
||||||
|
const amu = table.get('amu')
|
||||||
|
if (rin) expect(rin.type).toBe('ring')
|
||||||
|
if (amu) expect(amu.type).toBe('amul')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('quivers (aqv, cqv)', () => {
|
||||||
|
const aqv = table.get('aqv')
|
||||||
|
if (aqv) {
|
||||||
|
expect(aqv.type).toBe('bowq')
|
||||||
|
expect(aqv.stackable).toBe(true)
|
||||||
|
expect(aqv.minstack).toBe(100)
|
||||||
|
expect(aqv.maxstack).toBe(350)
|
||||||
|
expect(aqv.spawnstack).toBe(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cqv = table.get('cqv')
|
||||||
|
if (cqv) {
|
||||||
|
expect(cqv.type).toBe('xboq')
|
||||||
|
expect(cqv.stackable).toBe(true)
|
||||||
|
expect(cqv.minstack).toBe(60)
|
||||||
|
expect(cqv.maxstack).toBe(250)
|
||||||
|
expect(cqv.spawnstack).toBe(150)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Items with type2 and tags', () => {
|
||||||
|
it('maps type and type2 into tags for chipped/perfect gems and skulls', () => {
|
||||||
|
const gcv = table.get('gcv')
|
||||||
|
if (gcv) {
|
||||||
|
expect(gcv.name).toBe('Chipped Amethyst')
|
||||||
|
expect(gcv.type).toBe('gema')
|
||||||
|
expect(gcv.type2).toBe('gem0')
|
||||||
|
expect(gcv.tags).toEqual(['gema', 'gem0'])
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpw = table.get('gpw')
|
||||||
|
if (gpw) {
|
||||||
|
expect(gpw.name).toBe('Perfect Diamond')
|
||||||
|
expect(gpw.type).toBe('gemd')
|
||||||
|
expect(gpw.type2).toBe('gem4')
|
||||||
|
expect(gpw.tags).toEqual(['gemd', 'gem4'])
|
||||||
|
}
|
||||||
|
|
||||||
|
const skz = table.get('skz')
|
||||||
|
if (skz) {
|
||||||
|
expect(skz.name).toBe('Perfect Skull')
|
||||||
|
expect(skz.type).toBe('gemz')
|
||||||
|
expect(skz.type2).toBe('gem4')
|
||||||
|
expect(skz.tags).toEqual(['gemz', 'gem4'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Transmogrify flag', () => {
|
||||||
|
it('sets transmogrify === true for horadric quest transmogrify items', () => {
|
||||||
|
if (hasD2) {
|
||||||
|
for (const code of ['hrt', 'brz', 'jaw', 'eyz', 'hrn', 'tal', 'fng', 'qll', 'sol', 'scz', 'spe']) {
|
||||||
|
const item = table.get(code)!
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
expect(item.transmogrify).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(table.get('hp1')?.transmogrify).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Lookup & indexing flexibility', () => {
|
||||||
|
it('supports case-insensitive and whitespace-tolerant get and byCode lookups', () => {
|
||||||
|
const hp1 = table.get('hp1')!
|
||||||
|
expect(table.get('HP1')).toBe(hp1)
|
||||||
|
expect(table.get(' hp1 ')).toBe(hp1)
|
||||||
|
expect(table.get('Hp1')).toBe(hp1)
|
||||||
|
|
||||||
|
expect(table.byCode.get('HP1')).toBe(hp1)
|
||||||
|
expect(table.byCode.get(' hp1 ')).toBe(hp1)
|
||||||
|
expect(table.byCode.has('HP1')).toBe(true)
|
||||||
|
expect(table.byCode.has('nonexistent')).toBe(false)
|
||||||
|
expect(table.get('nonexistent')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseMiscTable overloads & standalone parsing', () => {
|
||||||
|
it('parses directly from a TSV string with Expansion row filtering', () => {
|
||||||
|
const tsv = [
|
||||||
|
'name\tcode\tnamestr\tversion\tspawnable\trarity\tlevel\ttype\ttype2\tcost\tgamble cost\tinvwidth\tinvheight\tstackable\tminstack\tmaxstack\tspawnstack\tautobelt\tpSpell\tTransmogrify',
|
||||||
|
'Lesser Healing Potion\thp1\thp1\t0\t1\t1\t0\thpot\t\t30\t\t1\t1\t0\t0\t0\t0\t1\t3\t0',
|
||||||
|
'Expansion\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t',
|
||||||
|
'El Rune\tr01\tr01\t100\t1\t1\t11\trune\t\t560\t\t1\t1\t0\t0\t0\t0\t0\t\t0',
|
||||||
|
'gold\tgld\tgld\t0\t1\t0\t0\tgold\t\t1\t\t1\t1\t1\t0\t5000\t0\t0\t\t0',
|
||||||
|
'Chipped Amethyst\tgcv\tgcv\t0\t1\t1\t1\tgema\tgem0\t240\t\t1\t1\t0\t0\t0\t0\t0\t\t0',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const parsed = parseMiscTable(tsv)
|
||||||
|
expect(parsed.all.length).toBe(4)
|
||||||
|
expect(parsed.byCode.size).toBe(4)
|
||||||
|
expect(parsed.get('Expansion')).toBeUndefined()
|
||||||
|
|
||||||
|
const hp1 = parsed.get('hp1')!
|
||||||
|
expect(hp1).toBeDefined()
|
||||||
|
expect(hp1.name).toBe('Lesser Healing Potion')
|
||||||
|
expect(hp1.pSpell).toBe(3)
|
||||||
|
expect(hp1.autobelt).toBe(true)
|
||||||
|
|
||||||
|
const rune = parsed.getRune(1)!
|
||||||
|
expect(rune).toBeDefined()
|
||||||
|
expect(rune.code).toBe('r01')
|
||||||
|
expect(rune.isRune).toBe(true)
|
||||||
|
expect(rune.runeNumber).toBe(1)
|
||||||
|
|
||||||
|
const gold = parsed.get('gld')!
|
||||||
|
expect(gold.isGold).toBe(true)
|
||||||
|
expect(gold.stackable).toBe(true)
|
||||||
|
expect(gold.maxstack).toBe(5000)
|
||||||
|
|
||||||
|
const gem = parsed.get('gcv')!
|
||||||
|
expect(gem.tags).toEqual(['gema', 'gem0'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses directly from Uint8Array bytes', () => {
|
||||||
|
const tsv = [
|
||||||
|
'name\tcode\tnamestr\tversion\tspawnable\trarity\tlevel\ttype\ttype2\tcost\tgamble cost\tinvwidth\tinvheight\tstackable\tminstack\tmaxstack\tspawnstack\tautobelt\tpSpell\tTransmogrify',
|
||||||
|
'Zod Rune\tr33\tr33\t100\t1\t30\t69\trune\t\t134540\t\t1\t1\t0\t0\t0\t0\t0\t\t0',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const bytes = new TextEncoder().encode(tsv)
|
||||||
|
const parsed = parseMiscTable(bytes)
|
||||||
|
expect(parsed.all.length).toBe(1)
|
||||||
|
expect(parsed.getRune(33)?.name).toBe('Zod Rune')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fallback D2Table for environments without MPQ assets.
|
||||||
|
*/
|
||||||
|
function createMinimalFallbackTable(): D2Table {
|
||||||
|
const header = [
|
||||||
|
'name', 'code', 'namestr', 'version', 'spawnable', 'rarity', 'level',
|
||||||
|
'type', 'type2', 'cost', 'gamble cost', 'invwidth', 'invheight',
|
||||||
|
'stackable', 'minstack', 'maxstack', 'spawnstack', 'autobelt', 'pSpell',
|
||||||
|
'Transmogrify',
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
['Lesser Healing Potion', 'hp1', 'hp1', '0', '1', '1', '0', 'hpot', '', '30', '', '1', '1', '0', '0', '0', '0', '1', '3', '0'],
|
||||||
|
['ring', 'rin', 'rin', '0', '1', '4', '1', 'ring', '', '1800', '50000', '1', '1', '0', '0', '0', '0', '0', '', '0'],
|
||||||
|
['Charm Small', 'cm1', 'cm1', '100', '1', '4', '28', 'scha', '', '2000', '45000', '1', '1', '0', '0', '0', '0', '0', '', '0'],
|
||||||
|
['gold', 'gld', 'gld', '0', '1', '0', '0', 'gold', '', '1', '', '1', '1', '1', '0', '5000', '0', '0', '', '0'],
|
||||||
|
['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''],
|
||||||
|
['El Rune', 'r01', 'r01', '100', '1', '1', '11', 'rune', '', '560', '', '1', '1', '0', '0', '0', '0', '0', '', '0'],
|
||||||
|
['Zod Rune', 'r33', 'r33', '100', '1', '30', '69', 'rune', '', '134540', '', '1', '1', '0', '0', '0', '0', '0', '', '0'],
|
||||||
|
]
|
||||||
|
|
||||||
|
return { header, rows }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue