feat(items): 加载 Armor.txt (Issue #91)
This commit is contained in:
parent
7187154ac8
commit
193c1209b2
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue