feat(items): 加载 ItemStatCost.txt 与 Properties.txt (Issue #90)
This commit is contained in:
commit
a1a549e8a0
|
|
@ -0,0 +1,801 @@
|
||||||
|
/**
|
||||||
|
* Diablo II ItemStatCost.txt and Properties.txt loader, property resolution,
|
||||||
|
* and serialization bitwidth definitions.
|
||||||
|
*
|
||||||
|
* References 1.13c:
|
||||||
|
* - ItemStatCost.txt: 359 rows (IDs 0..358, 9-bit in .d2s item saves), 53 columns.
|
||||||
|
* - Properties.txt: 268 data rows (filtering out 1 'Expansion' separator row).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { D2Table } from './acts.ts'
|
||||||
|
import { parseTable } from './acts.ts'
|
||||||
|
import type { MountedArchives } from '../mpq/mount.ts'
|
||||||
|
|
||||||
|
export const ITEM_STAT_COST_PATH = 'data\\global\\excel\\ItemStatCost.txt'
|
||||||
|
export const PROPERTIES_PATH = 'data\\global\\excel\\Properties.txt'
|
||||||
|
|
||||||
|
/** One record from ItemStatCost.txt. */
|
||||||
|
export interface ItemStatCostRecord {
|
||||||
|
/** Primary key name (e.g. 'strength', 'armorclass'). */
|
||||||
|
readonly Stat: string
|
||||||
|
/** Alias for Stat. */
|
||||||
|
readonly stat: string
|
||||||
|
/** Numeric stat ID (0..358, stored in 9 bits in .d2s item serialization). */
|
||||||
|
readonly id: number
|
||||||
|
/** Whether the stat value is signed. */
|
||||||
|
readonly signed: boolean
|
||||||
|
/** Bits used for transmission over network. */
|
||||||
|
readonly sendBits: number
|
||||||
|
/** Bits used for network transmission parameter. */
|
||||||
|
readonly sendParamBits: number
|
||||||
|
/** Whether this stat is saved. */
|
||||||
|
readonly saved: boolean
|
||||||
|
/** Bits used when saved on an item (.d2s serialization). Uses 'Save Bits', NOT '1.09-Save Bits'. */
|
||||||
|
readonly saveBits: number
|
||||||
|
/** Bias added before unsigned saving on an item. Uses 'Save Add', NOT '1.09-Save Add'. */
|
||||||
|
readonly saveAdd: number
|
||||||
|
/** Bits used for saved parameter. */
|
||||||
|
readonly saveParamBits: number
|
||||||
|
/** Operator code for calculation. */
|
||||||
|
readonly op: number
|
||||||
|
/** Parameter for operator. */
|
||||||
|
readonly opParam: number
|
||||||
|
/** Base stat for operator. */
|
||||||
|
readonly opBase: string
|
||||||
|
/** Target stat 1 for operator. */
|
||||||
|
readonly opStat1: string
|
||||||
|
/** Target stat 2 for operator. */
|
||||||
|
readonly opStat2: string
|
||||||
|
/** Target stat 3 for operator. */
|
||||||
|
readonly opStat3: string
|
||||||
|
/** Priority for item tooltip description sorting. */
|
||||||
|
readonly descPriority: number
|
||||||
|
/** Formatting function for item tooltip description. */
|
||||||
|
readonly descFunc: number
|
||||||
|
/** Position of value in description (0: none, 1: before, 2: after). */
|
||||||
|
readonly descVal: number
|
||||||
|
/** String key for positive value. */
|
||||||
|
readonly descStrPos: string
|
||||||
|
/** String key for negative value. */
|
||||||
|
readonly descStrNeg: string
|
||||||
|
/** Secondary string key. */
|
||||||
|
readonly descStr2: string
|
||||||
|
/** Display group ID for grouping multiple stats (e.g. all attributes). */
|
||||||
|
readonly dgrp: number
|
||||||
|
/** Formatting function for display group. */
|
||||||
|
readonly dgrpFunc: number
|
||||||
|
/** Value position for display group. */
|
||||||
|
readonly dgrpVal: number
|
||||||
|
/** String key for positive display group. */
|
||||||
|
readonly dgrpStrPos: string
|
||||||
|
/** String key for negative display group. */
|
||||||
|
readonly dgrpStrNeg: string
|
||||||
|
/** Secondary string key for display group. */
|
||||||
|
readonly dgrpStr2: string
|
||||||
|
/** Value precision shift. */
|
||||||
|
readonly valShift: number
|
||||||
|
/** Price divide factor. */
|
||||||
|
readonly divide: number
|
||||||
|
/** Price multiply factor. */
|
||||||
|
readonly multiply: number
|
||||||
|
/** Price add constant. */
|
||||||
|
readonly add: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsed ItemStatCost table with indexed lookups. */
|
||||||
|
export interface ItemStatCostTable {
|
||||||
|
/** The underlying raw parsed D2Table. */
|
||||||
|
readonly table: D2Table
|
||||||
|
/** All parsed records in table order. */
|
||||||
|
readonly records: readonly ItemStatCostRecord[]
|
||||||
|
/** Alias for records. */
|
||||||
|
readonly rows: readonly ItemStatCostRecord[]
|
||||||
|
/** Lookup map by stat name (exact match). */
|
||||||
|
readonly byStat: ReadonlyMap<string, ItemStatCostRecord>
|
||||||
|
/** Lookup map by numeric stat ID (0..358). */
|
||||||
|
readonly byId: ReadonlyMap<number, ItemStatCostRecord>
|
||||||
|
/** Lookup a record by stat name or numeric ID. */
|
||||||
|
get(statOrId: string | number): ItemStatCostRecord | undefined
|
||||||
|
/** Lookup a record by stat name. */
|
||||||
|
getByStat(stat: string): ItemStatCostRecord | undefined
|
||||||
|
/** Lookup a record by numeric stat ID. */
|
||||||
|
getById(id: number): ItemStatCostRecord | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stat modification slot within a property definition (1..7). */
|
||||||
|
export interface PropertyStatEntry {
|
||||||
|
/** Slot index (1..7). */
|
||||||
|
readonly index: number
|
||||||
|
/** Parameter/set value. */
|
||||||
|
readonly set: number
|
||||||
|
/** Parameter/class/group value. */
|
||||||
|
readonly val: number
|
||||||
|
/** Function code determining how parameters map to stats. */
|
||||||
|
readonly func: number
|
||||||
|
/** Target stat name (references ItemStatCost.txt). */
|
||||||
|
readonly stat: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One record from Properties.txt. */
|
||||||
|
export interface PropertyRecord {
|
||||||
|
/** Primary key property identifier (e.g. 'ac', 'str', 'dmg-cold'). */
|
||||||
|
readonly code: string
|
||||||
|
/** Active stat modification slots (up to 7). */
|
||||||
|
readonly stats: readonly PropertyStatEntry[]
|
||||||
|
// Flat properties for direct column access
|
||||||
|
readonly set1: number
|
||||||
|
readonly val1: number
|
||||||
|
readonly func1: number
|
||||||
|
readonly stat1: string
|
||||||
|
readonly set2: number
|
||||||
|
readonly val2: number
|
||||||
|
readonly func2: number
|
||||||
|
readonly stat2: string
|
||||||
|
readonly set3: number
|
||||||
|
readonly val3: number
|
||||||
|
readonly func3: number
|
||||||
|
readonly stat3: string
|
||||||
|
readonly set4: number
|
||||||
|
readonly val4: number
|
||||||
|
readonly func4: number
|
||||||
|
readonly stat4: string
|
||||||
|
readonly set5: number
|
||||||
|
readonly val5: number
|
||||||
|
readonly func5: number
|
||||||
|
readonly stat5: string
|
||||||
|
readonly set6: number
|
||||||
|
readonly val6: number
|
||||||
|
readonly func6: number
|
||||||
|
readonly stat6: string
|
||||||
|
readonly set7: number
|
||||||
|
readonly val7: number
|
||||||
|
readonly func7: number
|
||||||
|
readonly stat7: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsed Properties table with indexed lookups and property resolution. */
|
||||||
|
export interface PropertiesTable {
|
||||||
|
/** The underlying raw parsed D2Table. */
|
||||||
|
readonly table: D2Table
|
||||||
|
/** All parsed property records in table order. */
|
||||||
|
readonly records: readonly PropertyRecord[]
|
||||||
|
/** Alias for records. */
|
||||||
|
readonly rows: readonly PropertyRecord[]
|
||||||
|
/** Lookup map by property code. */
|
||||||
|
readonly byCode: ReadonlyMap<string, PropertyRecord>
|
||||||
|
/** Lookup a property by code. */
|
||||||
|
get(code: string): PropertyRecord | undefined
|
||||||
|
/** Lookup a property by code. */
|
||||||
|
getByCode(code: string): PropertyRecord | undefined
|
||||||
|
/** Resolve a property into concrete stat modifications. */
|
||||||
|
resolveProperty(
|
||||||
|
propCode: string,
|
||||||
|
param?: number,
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
stats?: ItemStatCostTable
|
||||||
|
): ResolvedStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of resolving a property into a concrete stat modification. */
|
||||||
|
export interface ResolvedStat {
|
||||||
|
/** Stat name as specified in ItemStatCost.txt. */
|
||||||
|
readonly stat: string
|
||||||
|
/** Numeric stat ID (0..358) from ItemStatCost.txt. */
|
||||||
|
readonly statId: number
|
||||||
|
/** Alias for statId. */
|
||||||
|
readonly id: number
|
||||||
|
/** Function code from Properties.txt. */
|
||||||
|
readonly func: number
|
||||||
|
/** Resolved minimum stat bonus. */
|
||||||
|
readonly min: number
|
||||||
|
/** Resolved maximum stat bonus. */
|
||||||
|
readonly max: number
|
||||||
|
/** Resolved stat bonus value. */
|
||||||
|
readonly value: number
|
||||||
|
/** Optional parameter (e.g. skill ID, class ID, duration frames). */
|
||||||
|
readonly param?: number | undefined
|
||||||
|
/** Serialization bitwidth: Save Bits from ItemStatCost.txt. */
|
||||||
|
readonly saveBits: number
|
||||||
|
/** Serialization bias: Save Add from ItemStatCost.txt. */
|
||||||
|
readonly saveAdd: number
|
||||||
|
/** Parameter bitwidth: Save Param Bits from ItemStatCost.txt. */
|
||||||
|
readonly saveParamBits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level default tables loaded for convenience
|
||||||
|
let defaultStatsTable: ItemStatCostTable | undefined
|
||||||
|
let defaultPropertiesTable: PropertiesTable | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure default ItemStatCost and Properties tables used by top-level resolveProperty().
|
||||||
|
*/
|
||||||
|
export function setDefaultItemPropertiesAndStats(tables: {
|
||||||
|
stats?: ItemStatCostTable
|
||||||
|
properties?: PropertiesTable
|
||||||
|
}): void {
|
||||||
|
if (tables.stats) defaultStatsTable = tables.stats
|
||||||
|
if (tables.properties) defaultPropertiesTable = tables.properties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the currently configured default tables.
|
||||||
|
*/
|
||||||
|
export function getDefaultItemPropertiesAndStats(): {
|
||||||
|
stats?: ItemStatCostTable | undefined
|
||||||
|
properties?: PropertiesTable | undefined
|
||||||
|
} {
|
||||||
|
return { stats: defaultStatsTable, properties: defaultPropertiesTable }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to safely extract a trimmed cell from a D2Table row by exact or case-insensitive column name.
|
||||||
|
*/
|
||||||
|
function getCell(table: D2Table, row: readonly string[], colName: string): string {
|
||||||
|
const exact = table.header.indexOf(colName)
|
||||||
|
if (exact !== -1) return (row[exact] ?? '').trim()
|
||||||
|
const lower = colName.toLowerCase()
|
||||||
|
const foundIdx = table.header.findIndex(h => h.trim().toLowerCase() === lower)
|
||||||
|
if (foundIdx !== -1) return (row[foundIdx] ?? '').trim()
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to parse a numeric cell from a D2Table row.
|
||||||
|
*/
|
||||||
|
function getNum(table: D2Table, row: readonly string[], colName: string, fallback = 0): number {
|
||||||
|
const text = getCell(table, row, colName)
|
||||||
|
if (!text) return fallback
|
||||||
|
const n = Number(text)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to parse a boolean flag cell ('1' => true) from a D2Table row.
|
||||||
|
*/
|
||||||
|
function getBool(table: D2Table, row: readonly string[], colName: string): boolean {
|
||||||
|
const text = getCell(table, row, colName)
|
||||||
|
return text === '1' || text.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an ItemStatCost.txt D2Table into an ItemStatCostTable.
|
||||||
|
*
|
||||||
|
* Expects 359 rows with IDs 0..358.
|
||||||
|
* NOTE: Uses 'Save Bits' and 'Save Add', NOT '1.09-Save Bits' or '1.09-Save Add'.
|
||||||
|
*/
|
||||||
|
export function parseItemStatCostTable(table: D2Table): ItemStatCostTable {
|
||||||
|
const records: ItemStatCostRecord[] = []
|
||||||
|
const byStat = new Map<string, ItemStatCostRecord>()
|
||||||
|
const byId = new Map<number, ItemStatCostRecord>()
|
||||||
|
|
||||||
|
for (const raw of table.rows) {
|
||||||
|
const stat = getCell(table, raw, 'Stat')
|
||||||
|
if (!stat) continue
|
||||||
|
|
||||||
|
const id = getNum(table, raw, 'ID', -1)
|
||||||
|
const signed = getBool(table, raw, 'Signed')
|
||||||
|
const sendBits = getNum(table, raw, 'Send Bits')
|
||||||
|
const sendParamBits = getNum(table, raw, 'Send Param Bits')
|
||||||
|
const saved = getBool(table, raw, 'Saved')
|
||||||
|
|
||||||
|
// CRITICAL: Use 'Save Bits' / 'Save Add' (exact column lookup), NOT '1.09-Save Bits'
|
||||||
|
const saveBits = getNum(table, raw, 'Save Bits')
|
||||||
|
const saveAdd = getNum(table, raw, 'Save Add')
|
||||||
|
const saveParamBits = getNum(table, raw, 'Save Param Bits')
|
||||||
|
|
||||||
|
const op = getNum(table, raw, 'op')
|
||||||
|
const opParam = getNum(table, raw, 'op param')
|
||||||
|
const opBase = getCell(table, raw, 'op base')
|
||||||
|
const opStat1 = getCell(table, raw, 'op stat1')
|
||||||
|
const opStat2 = getCell(table, raw, 'op stat2')
|
||||||
|
const opStat3 = getCell(table, raw, 'op stat3')
|
||||||
|
|
||||||
|
const descPriority = getNum(table, raw, 'descpriority')
|
||||||
|
const descFunc = getNum(table, raw, 'descfunc')
|
||||||
|
const descVal = getNum(table, raw, 'descval')
|
||||||
|
const descStrPos = getCell(table, raw, 'descstrpos')
|
||||||
|
const descStrNeg = getCell(table, raw, 'descstrneg')
|
||||||
|
const descStr2 = getCell(table, raw, 'descstr2')
|
||||||
|
|
||||||
|
const dgrp = getNum(table, raw, 'dgrp')
|
||||||
|
const dgrpFunc = getNum(table, raw, 'dgrpfunc')
|
||||||
|
const dgrpVal = getNum(table, raw, 'dgrpval')
|
||||||
|
const dgrpStrPos = getCell(table, raw, 'dgrpstrpos')
|
||||||
|
const dgrpStrNeg = getCell(table, raw, 'dgrpstrneg')
|
||||||
|
const dgrpStr2 = getCell(table, raw, 'dgrpstr2')
|
||||||
|
|
||||||
|
const valShift = getNum(table, raw, 'ValShift')
|
||||||
|
const divide = getNum(table, raw, 'Divide')
|
||||||
|
const multiply = getNum(table, raw, 'Multiply')
|
||||||
|
const add = getNum(table, raw, 'Add')
|
||||||
|
|
||||||
|
const record: ItemStatCostRecord = {
|
||||||
|
Stat: stat,
|
||||||
|
stat,
|
||||||
|
id,
|
||||||
|
signed,
|
||||||
|
sendBits,
|
||||||
|
sendParamBits,
|
||||||
|
saved,
|
||||||
|
saveBits,
|
||||||
|
saveAdd,
|
||||||
|
saveParamBits,
|
||||||
|
op,
|
||||||
|
opParam,
|
||||||
|
opBase,
|
||||||
|
opStat1,
|
||||||
|
opStat2,
|
||||||
|
opStat3,
|
||||||
|
descPriority,
|
||||||
|
descFunc,
|
||||||
|
descVal,
|
||||||
|
descStrPos,
|
||||||
|
descStrNeg,
|
||||||
|
descStr2,
|
||||||
|
dgrp,
|
||||||
|
dgrpFunc,
|
||||||
|
dgrpVal,
|
||||||
|
dgrpStrPos,
|
||||||
|
dgrpStrNeg,
|
||||||
|
dgrpStr2,
|
||||||
|
valShift,
|
||||||
|
divide,
|
||||||
|
multiply,
|
||||||
|
add,
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push(record)
|
||||||
|
byStat.set(stat, record)
|
||||||
|
if (id >= 0) {
|
||||||
|
byId.set(id, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ItemStatCostTable = {
|
||||||
|
table,
|
||||||
|
records,
|
||||||
|
rows: records,
|
||||||
|
byStat,
|
||||||
|
byId,
|
||||||
|
get(statOrId: string | number): ItemStatCostRecord | undefined {
|
||||||
|
if (typeof statOrId === 'number') {
|
||||||
|
return byId.get(statOrId)
|
||||||
|
}
|
||||||
|
return byStat.get(statOrId)
|
||||||
|
},
|
||||||
|
getByStat(stat: string): ItemStatCostRecord | undefined {
|
||||||
|
return byStat.get(stat)
|
||||||
|
},
|
||||||
|
getById(id: number): ItemStatCostRecord | undefined {
|
||||||
|
return byId.get(id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Properties.txt D2Table into a PropertiesTable.
|
||||||
|
*
|
||||||
|
* Filters out comment columns starting with '*' and the 'Expansion' separator row.
|
||||||
|
* Produces exactly 268 records from the canonical 269-row table.
|
||||||
|
*/
|
||||||
|
export function parsePropertiesTable(
|
||||||
|
table: D2Table,
|
||||||
|
statsTable?: ItemStatCostTable
|
||||||
|
): PropertiesTable {
|
||||||
|
const records: PropertyRecord[] = []
|
||||||
|
const byCode = new Map<string, PropertyRecord>()
|
||||||
|
|
||||||
|
for (const raw of table.rows) {
|
||||||
|
const code = getCell(table, raw, 'code')
|
||||||
|
// Filter empty rows and the 'Expansion' separator row
|
||||||
|
if (!code || code === 'Expansion') continue
|
||||||
|
|
||||||
|
const set1 = getNum(table, raw, 'set1')
|
||||||
|
const val1 = getNum(table, raw, 'val1')
|
||||||
|
const func1 = getNum(table, raw, 'func1')
|
||||||
|
const stat1 = getCell(table, raw, 'stat1')
|
||||||
|
|
||||||
|
const set2 = getNum(table, raw, 'set2')
|
||||||
|
const val2 = getNum(table, raw, 'val2')
|
||||||
|
const func2 = getNum(table, raw, 'func2')
|
||||||
|
const stat2 = getCell(table, raw, 'stat2')
|
||||||
|
|
||||||
|
const set3 = getNum(table, raw, 'set3')
|
||||||
|
const val3 = getNum(table, raw, 'val3')
|
||||||
|
const func3 = getNum(table, raw, 'func3')
|
||||||
|
const stat3 = getCell(table, raw, 'stat3')
|
||||||
|
|
||||||
|
const set4 = getNum(table, raw, 'set4')
|
||||||
|
const val4 = getNum(table, raw, 'val4')
|
||||||
|
const func4 = getNum(table, raw, 'func4')
|
||||||
|
const stat4 = getCell(table, raw, 'stat4')
|
||||||
|
|
||||||
|
const set5 = getNum(table, raw, 'set5')
|
||||||
|
const val5 = getNum(table, raw, 'val5')
|
||||||
|
const func5 = getNum(table, raw, 'func5')
|
||||||
|
const stat5 = getCell(table, raw, 'stat5')
|
||||||
|
|
||||||
|
const set6 = getNum(table, raw, 'set6')
|
||||||
|
const val6 = getNum(table, raw, 'val6')
|
||||||
|
const func6 = getNum(table, raw, 'func6')
|
||||||
|
const stat6 = getCell(table, raw, 'stat6')
|
||||||
|
|
||||||
|
const set7 = getNum(table, raw, 'set7')
|
||||||
|
const val7 = getNum(table, raw, 'val7')
|
||||||
|
const func7 = getNum(table, raw, 'func7')
|
||||||
|
const stat7 = getCell(table, raw, 'stat7')
|
||||||
|
|
||||||
|
const rawSlots: [number, number, number, string][] = [
|
||||||
|
[set1, val1, func1, stat1],
|
||||||
|
[set2, val2, func2, stat2],
|
||||||
|
[set3, val3, func3, stat3],
|
||||||
|
[set4, val4, func4, stat4],
|
||||||
|
[set5, val5, func5, stat5],
|
||||||
|
[set6, val6, func6, stat6],
|
||||||
|
[set7, val7, func7, stat7],
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats: PropertyStatEntry[] = []
|
||||||
|
rawSlots.forEach(([set, val, func, stat], idx) => {
|
||||||
|
if (func > 0 || stat !== '' || set !== 0 || val !== 0) {
|
||||||
|
stats.push({
|
||||||
|
index: idx + 1,
|
||||||
|
set,
|
||||||
|
val,
|
||||||
|
func,
|
||||||
|
stat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const record: PropertyRecord = {
|
||||||
|
code,
|
||||||
|
stats,
|
||||||
|
set1,
|
||||||
|
val1,
|
||||||
|
func1,
|
||||||
|
stat1,
|
||||||
|
set2,
|
||||||
|
val2,
|
||||||
|
func2,
|
||||||
|
stat2,
|
||||||
|
set3,
|
||||||
|
val3,
|
||||||
|
func3,
|
||||||
|
stat3,
|
||||||
|
set4,
|
||||||
|
val4,
|
||||||
|
func4,
|
||||||
|
stat4,
|
||||||
|
set5,
|
||||||
|
val5,
|
||||||
|
func5,
|
||||||
|
stat5,
|
||||||
|
set6,
|
||||||
|
val6,
|
||||||
|
func6,
|
||||||
|
stat6,
|
||||||
|
set7,
|
||||||
|
val7,
|
||||||
|
func7,
|
||||||
|
stat7,
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push(record)
|
||||||
|
byCode.set(code, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkedStats = statsTable
|
||||||
|
|
||||||
|
const result: PropertiesTable = {
|
||||||
|
table,
|
||||||
|
records,
|
||||||
|
rows: records,
|
||||||
|
byCode,
|
||||||
|
get(code: string): PropertyRecord | undefined {
|
||||||
|
return byCode.get(code)
|
||||||
|
},
|
||||||
|
getByCode(code: string): PropertyRecord | undefined {
|
||||||
|
return byCode.get(code)
|
||||||
|
},
|
||||||
|
resolveProperty(
|
||||||
|
propCode: string,
|
||||||
|
param?: number,
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
overrideStats?: ItemStatCostTable
|
||||||
|
): ResolvedStat[] {
|
||||||
|
return resolveProperty(propCode, param, min, max, {
|
||||||
|
properties: result,
|
||||||
|
stats: overrideStats ?? linkedStats ?? defaultStatsTable,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load both ItemStatCost.txt and Properties.txt from mounted MPQ archives.
|
||||||
|
*/
|
||||||
|
export async function loadItemPropertiesAndStats(
|
||||||
|
archives: MountedArchives
|
||||||
|
): Promise<{ stats: ItemStatCostTable; properties: PropertiesTable }> {
|
||||||
|
const statsRaw = await archives.read(ITEM_STAT_COST_PATH)
|
||||||
|
const stats = parseItemStatCostTable(parseTable(statsRaw))
|
||||||
|
|
||||||
|
const propertiesRaw = await archives.read(PROPERTIES_PATH)
|
||||||
|
const properties = parsePropertiesTable(parseTable(propertiesRaw), stats)
|
||||||
|
|
||||||
|
setDefaultItemPropertiesAndStats({ stats, properties })
|
||||||
|
return { stats, properties }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context or options for resolveProperty.
|
||||||
|
*/
|
||||||
|
export interface PropertyResolutionContext {
|
||||||
|
readonly properties?: PropertiesTable | undefined
|
||||||
|
readonly stats?: ItemStatCostTable | undefined
|
||||||
|
readonly value?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a property code into concrete stat modifications with resolved stat ID, function, min, max, value.
|
||||||
|
*
|
||||||
|
* Handles standard property function mapping:
|
||||||
|
* - Func 1, 2, 8, 13, 14: standard stat bonuses
|
||||||
|
* - Func 3, 9: range inheritance from preceding slot (e.g. 'all-stats', 'res-all')
|
||||||
|
* - Func 5, 6, 7: damage min/max/percent
|
||||||
|
* - Func 10: skill tab bonuses
|
||||||
|
* - Func 11: chance to cast skills
|
||||||
|
* - Func 15: use min field only (e.g. dmg-cold min)
|
||||||
|
* - Func 16: use max field only (e.g. dmg-cold max)
|
||||||
|
* - Func 17: use param field only (e.g. dmg-cold length)
|
||||||
|
* - Func 20: indestructible
|
||||||
|
* - Func 21, 36: class skills
|
||||||
|
* - Func 22: individual skill / aura / oskill
|
||||||
|
*
|
||||||
|
* @param propCode - property code (e.g. 'ac', 'str', 'dmg-cold', 'all-stats')
|
||||||
|
* @param param - optional property parameter (e.g. skill ID, duration frames)
|
||||||
|
* @param min - optional minimum roll
|
||||||
|
* @param max - optional maximum roll
|
||||||
|
* @param context - optional table overrides or context
|
||||||
|
* @returns array of resolved stat modifications
|
||||||
|
*/
|
||||||
|
export function resolveProperty(
|
||||||
|
propCode: string,
|
||||||
|
param?: number,
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
context?: PropertyResolutionContext | PropertiesTable
|
||||||
|
): ResolvedStat[] {
|
||||||
|
let propTable: PropertiesTable | undefined
|
||||||
|
let statTable: ItemStatCostTable | undefined
|
||||||
|
let customValue: number | undefined
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
if ('byCode' in context) {
|
||||||
|
propTable = context
|
||||||
|
} else {
|
||||||
|
propTable = context.properties
|
||||||
|
statTable = context.stats
|
||||||
|
customValue = context.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
propTable = propTable ?? defaultPropertiesTable
|
||||||
|
statTable = statTable ?? defaultStatsTable
|
||||||
|
|
||||||
|
if (!propTable) {
|
||||||
|
throw new Error(
|
||||||
|
`resolveProperty: Properties table is not available. Call loadItemPropertiesAndStats() first or pass tables in context.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prop = propTable.getByCode(propCode)
|
||||||
|
if (!prop) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: ResolvedStat[] = []
|
||||||
|
let previousResolved: { min: number; max: number; value: number; param?: number | undefined } | null = null
|
||||||
|
|
||||||
|
for (const entry of prop.stats) {
|
||||||
|
const func = entry.func
|
||||||
|
let statName = entry.stat
|
||||||
|
let resolvedMin = min ?? 0
|
||||||
|
let resolvedMax = max ?? min ?? 0
|
||||||
|
let resolvedVal =
|
||||||
|
customValue !== undefined
|
||||||
|
? customValue
|
||||||
|
: min !== undefined
|
||||||
|
? min
|
||||||
|
: max !== undefined
|
||||||
|
? max
|
||||||
|
: 0
|
||||||
|
let resolvedParam = param
|
||||||
|
|
||||||
|
switch (func) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
case 8:
|
||||||
|
case 13:
|
||||||
|
case 14:
|
||||||
|
if (entry.set !== 0 && resolvedParam === undefined) {
|
||||||
|
resolvedParam = entry.set
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
case 9:
|
||||||
|
// Inherit range from previous slot in the same property (e.g. all-stats, res-all)
|
||||||
|
if (previousResolved) {
|
||||||
|
resolvedMin = previousResolved.min
|
||||||
|
resolvedMax = previousResolved.max
|
||||||
|
resolvedVal = previousResolved.value
|
||||||
|
if (func === 9 || resolvedParam === undefined) {
|
||||||
|
resolvedParam = previousResolved.param
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
// dmg-min
|
||||||
|
if (!statName) statName = 'mindamage'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 6:
|
||||||
|
// dmg-max
|
||||||
|
if (!statName) statName = 'maxdamage'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 7:
|
||||||
|
// dmg%
|
||||||
|
if (!statName) statName = 'item_maxdamage_percent'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 10:
|
||||||
|
// skilltab bonus
|
||||||
|
if (!statName) statName = 'item_addskill_tab'
|
||||||
|
if (resolvedParam === undefined && entry.val !== 0) {
|
||||||
|
resolvedParam = entry.val
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 11:
|
||||||
|
// Chance to cast skills (min = chance%, max = skill level)
|
||||||
|
resolvedMin = min ?? 0
|
||||||
|
resolvedMax = max ?? min ?? 0
|
||||||
|
resolvedVal =
|
||||||
|
customValue !== undefined
|
||||||
|
? customValue
|
||||||
|
: min !== undefined
|
||||||
|
? min
|
||||||
|
: max !== undefined
|
||||||
|
? max
|
||||||
|
: 0
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 12:
|
||||||
|
// Random class skill
|
||||||
|
if (!statName) statName = 'item_singleskill'
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 15:
|
||||||
|
// Use min field only (e.g. coldmindam)
|
||||||
|
resolvedMin = min ?? 0
|
||||||
|
resolvedMax = min ?? 0
|
||||||
|
resolvedVal = customValue !== undefined ? customValue : (min ?? 0)
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 16:
|
||||||
|
// Use max field only (e.g. coldmaxdam)
|
||||||
|
resolvedMin = max ?? min ?? 0
|
||||||
|
resolvedMax = max ?? min ?? 0
|
||||||
|
resolvedVal = customValue !== undefined ? customValue : (max ?? min ?? 0)
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 17:
|
||||||
|
// Use param field only (e.g. coldlength)
|
||||||
|
resolvedMin = param ?? 0
|
||||||
|
resolvedMax = param ?? 0
|
||||||
|
resolvedVal = customValue !== undefined ? customValue : (param ?? 0)
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 18:
|
||||||
|
// /time property
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 19:
|
||||||
|
// Charged skill
|
||||||
|
if (!statName) statName = 'item_charged_skill'
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 20:
|
||||||
|
// Indestructible
|
||||||
|
if (!statName) statName = 'item_indesctructible'
|
||||||
|
resolvedMin = 1
|
||||||
|
resolvedMax = 1
|
||||||
|
resolvedVal = 1
|
||||||
|
break
|
||||||
|
|
||||||
|
case 21:
|
||||||
|
case 36:
|
||||||
|
// Class skills (param = class ID from val)
|
||||||
|
if (!statName) statName = 'item_addclassskills'
|
||||||
|
resolvedParam = entry.val
|
||||||
|
break
|
||||||
|
|
||||||
|
case 22:
|
||||||
|
// Individual skill / aura / oskill (param = skill ID)
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
case 23:
|
||||||
|
// Ethereal
|
||||||
|
break
|
||||||
|
|
||||||
|
case 24:
|
||||||
|
// Monster-specific bonus
|
||||||
|
resolvedParam = param
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (entry.set !== 0 && resolvedParam === undefined) {
|
||||||
|
resolvedParam = entry.set
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup stat metadata from ItemStatCostTable
|
||||||
|
let statId = -1
|
||||||
|
let saveBits = 0
|
||||||
|
let saveAdd = 0
|
||||||
|
let saveParamBits = 0
|
||||||
|
|
||||||
|
if (statTable && statName) {
|
||||||
|
const statRecord = statTable.getByStat(statName)
|
||||||
|
if (statRecord) {
|
||||||
|
statId = statRecord.id
|
||||||
|
saveBits = statRecord.saveBits
|
||||||
|
saveAdd = statRecord.saveAdd
|
||||||
|
saveParamBits = statRecord.saveParamBits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved: ResolvedStat = {
|
||||||
|
stat: statName,
|
||||||
|
statId,
|
||||||
|
id: statId,
|
||||||
|
func,
|
||||||
|
min: resolvedMin,
|
||||||
|
max: resolvedMax,
|
||||||
|
value: resolvedVal,
|
||||||
|
param: resolvedParam,
|
||||||
|
saveBits,
|
||||||
|
saveAdd,
|
||||||
|
saveParamBits,
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(resolved)
|
||||||
|
previousResolved = {
|
||||||
|
min: resolvedMin,
|
||||||
|
max: resolvedMax,
|
||||||
|
value: resolvedVal,
|
||||||
|
param: resolvedParam,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,446 @@
|
||||||
|
import { describe, expect, it, beforeAll } from 'vitest'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
|
import { parseTable } from '../src/game/acts.ts'
|
||||||
|
import type { D2Table } from '../src/game/acts.ts'
|
||||||
|
import {
|
||||||
|
parseItemStatCostTable,
|
||||||
|
parsePropertiesTable,
|
||||||
|
loadItemPropertiesAndStats,
|
||||||
|
resolveProperty,
|
||||||
|
setDefaultItemPropertiesAndStats,
|
||||||
|
} from '../src/game/properties.ts'
|
||||||
|
import type {
|
||||||
|
ItemStatCostTable,
|
||||||
|
PropertiesTable,
|
||||||
|
ItemStatCostRecord,
|
||||||
|
PropertyRecord,
|
||||||
|
ResolvedStat,
|
||||||
|
} from '../src/game/properties.ts'
|
||||||
|
|
||||||
|
describe('Unit: Standalone Table Parsing', () => {
|
||||||
|
it('parses synthetic ItemStatCost table rows correctly', () => {
|
||||||
|
const rawTsv = [
|
||||||
|
'Stat\tID\tSigned\tSend Bits\tSend Param Bits\tSaved\t1.09-Save Bits\t1.09-Save Add\tSave Bits\tSave Add\tSave Param Bits\top\top param\top base\top stat1\top stat2\top stat3\tdescpriority\tdescfunc\tdescval\tdescstrpos\tdescstrneg\tdescstr2\tdgrp\tdgrpfunc\tdgrpval\tdgrpstrpos\tdgrpstrneg\tdgrpstr2\t*eol',
|
||||||
|
'strength\t0\t\t11\t\t1\t7\t32\t8\t32\t\t\t\t\t\t\t\t67\t1\t1\tModStr1a\tModStr1a\t\t1\t1\t1\tModitem2allattrib\tModitem2allattrib\t\t0',
|
||||||
|
'armorclass\t31\t\t11\t\t1\t9\t10\t11\t10\t\t\t\t\t\t\t\t70\t1\t1\tModStr1h\tModStr1h\t\t\t\t\t\t\t\t0',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const d2Table = parseTable(new TextEncoder().encode(rawTsv))
|
||||||
|
const isc = parseItemStatCostTable(d2Table)
|
||||||
|
|
||||||
|
expect(isc.records).toHaveLength(2)
|
||||||
|
expect(isc.rows).toHaveLength(2)
|
||||||
|
|
||||||
|
const str = isc.get('strength')
|
||||||
|
expect(str).toBeDefined()
|
||||||
|
expect(str?.id).toBe(0)
|
||||||
|
expect(str?.Stat).toBe('strength')
|
||||||
|
expect(str?.stat).toBe('strength')
|
||||||
|
expect(str?.saved).toBe(true)
|
||||||
|
expect(str?.signed).toBe(false)
|
||||||
|
expect(str?.sendBits).toBe(11)
|
||||||
|
// Verify it picks Save Bits (8), NOT 1.09-Save Bits (7)
|
||||||
|
expect(str?.saveBits).toBe(8)
|
||||||
|
expect(str?.saveAdd).toBe(32)
|
||||||
|
expect(str?.descPriority).toBe(67)
|
||||||
|
expect(str?.descFunc).toBe(1)
|
||||||
|
expect(str?.descVal).toBe(1)
|
||||||
|
expect(str?.descStrPos).toBe('ModStr1a')
|
||||||
|
expect(str?.dgrp).toBe(1)
|
||||||
|
|
||||||
|
// Lookup by ID
|
||||||
|
expect(isc.get(0)).toBe(str)
|
||||||
|
expect(isc.getById(0)).toBe(str)
|
||||||
|
expect(isc.getByStat('strength')).toBe(str)
|
||||||
|
|
||||||
|
const ac = isc.get(31)
|
||||||
|
expect(ac).toBeDefined()
|
||||||
|
expect(ac?.Stat).toBe('armorclass')
|
||||||
|
expect(ac?.saveBits).toBe(11)
|
||||||
|
expect(ac?.saveAdd).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses synthetic Properties table and filters Expansion row', () => {
|
||||||
|
const rawTsv = [
|
||||||
|
'code\t*done\tset1\tval1\tfunc1\tstat1\tset2\tval2\tfunc2\tstat2\t*desc\t*eol',
|
||||||
|
'ac\t1\t\t\t1\tarmorclass\t\t\t\t\tDefense\t0',
|
||||||
|
'Expansion\t0\t\t\t\t\t\t\t\t\tExpansion Separator\t0',
|
||||||
|
'str\t1\t\t\t1\tstrength\t\t\t\t\tStrength\t0',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const d2Table = parseTable(new TextEncoder().encode(rawTsv))
|
||||||
|
const propTable = parsePropertiesTable(d2Table)
|
||||||
|
|
||||||
|
expect(propTable.records).toHaveLength(2)
|
||||||
|
expect(propTable.get('Expansion')).toBeUndefined()
|
||||||
|
|
||||||
|
const ac = propTable.get('ac')
|
||||||
|
expect(ac).toBeDefined()
|
||||||
|
expect(ac?.code).toBe('ac')
|
||||||
|
expect(ac?.func1).toBe(1)
|
||||||
|
expect(ac?.stat1).toBe('armorclass')
|
||||||
|
expect(ac?.stats).toHaveLength(1)
|
||||||
|
expect(ac?.stats[0]).toEqual({
|
||||||
|
index: 1,
|
||||||
|
set: 0,
|
||||||
|
val: 0,
|
||||||
|
func: 1,
|
||||||
|
stat: 'armorclass',
|
||||||
|
})
|
||||||
|
|
||||||
|
const str = propTable.get('str')
|
||||||
|
expect(str).toBeDefined()
|
||||||
|
expect(str?.code).toBe('str')
|
||||||
|
expect(str?.func1).toBe(1)
|
||||||
|
expect(str?.stat1).toBe('strength')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||||
|
|
||||||
|
describe.skipIf(!hasD2)('ItemStatCost.txt & Properties.txt 1.13c Integration Tests', () => {
|
||||||
|
let archives: MountedArchives
|
||||||
|
let stats: ItemStatCostTable
|
||||||
|
let properties: PropertiesTable
|
||||||
|
|
||||||
|
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 loaded = await loadItemPropertiesAndStats(archives)
|
||||||
|
stats = loaded.stats
|
||||||
|
properties = loaded.properties
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies 359 stats parsed in ItemStatCost.txt with sequential IDs 0..358', () => {
|
||||||
|
expect(stats.records).toHaveLength(359)
|
||||||
|
expect(stats.rows).toHaveLength(359)
|
||||||
|
expect(stats.byStat.size).toBe(359)
|
||||||
|
expect(stats.byId.size).toBe(359)
|
||||||
|
|
||||||
|
// IDs must be 0..358 sequentially
|
||||||
|
for (let id = 0; id <= 358; id++) {
|
||||||
|
const record = stats.getById(id)
|
||||||
|
expect(record).toBeDefined()
|
||||||
|
expect(record?.id).toBe(id)
|
||||||
|
expect(record?.Stat.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key stats verification
|
||||||
|
const str = stats.getByStat('strength')!
|
||||||
|
expect(str).toBeDefined()
|
||||||
|
expect(str.id).toBe(0)
|
||||||
|
expect(str.saved).toBe(true)
|
||||||
|
expect(str.signed).toBe(false)
|
||||||
|
expect(str.sendBits).toBe(11)
|
||||||
|
expect(str.saveBits).toBe(8)
|
||||||
|
expect(str.saveAdd).toBe(32)
|
||||||
|
expect(str.descPriority).toBe(67)
|
||||||
|
expect(str.descFunc).toBe(1)
|
||||||
|
expect(str.descVal).toBe(1)
|
||||||
|
expect(str.descStrPos).toBe('ModStr1a')
|
||||||
|
expect(str.dgrp).toBe(1)
|
||||||
|
|
||||||
|
const energy = stats.getByStat('energy')!
|
||||||
|
expect(energy.id).toBe(1)
|
||||||
|
expect(energy.saveBits).toBe(7)
|
||||||
|
expect(energy.saveAdd).toBe(32)
|
||||||
|
expect(energy.op).toBe(8)
|
||||||
|
expect(energy.opStat1).toBe('maxmana')
|
||||||
|
|
||||||
|
const dex = stats.getByStat('dexterity')!
|
||||||
|
expect(dex.id).toBe(2)
|
||||||
|
expect(dex.saveBits).toBe(7)
|
||||||
|
expect(dex.saveAdd).toBe(32)
|
||||||
|
|
||||||
|
const vit = stats.getByStat('vitality')!
|
||||||
|
expect(vit.id).toBe(3)
|
||||||
|
expect(vit.saveBits).toBe(7)
|
||||||
|
expect(vit.saveAdd).toBe(32)
|
||||||
|
expect(vit.op).toBe(9)
|
||||||
|
expect(vit.opStat1).toBe('maxhp')
|
||||||
|
expect(vit.opStat2).toBe('maxstamina')
|
||||||
|
|
||||||
|
const ac = stats.getByStat('armorclass')!
|
||||||
|
expect(ac.id).toBe(31)
|
||||||
|
expect(ac.saveBits).toBe(11)
|
||||||
|
expect(ac.saveAdd).toBe(10)
|
||||||
|
|
||||||
|
const coldMin = stats.getByStat('coldmindam')!
|
||||||
|
expect(coldMin.id).toBe(54)
|
||||||
|
expect(coldMin.saveBits).toBe(8)
|
||||||
|
expect(coldMin.saveAdd).toBe(0)
|
||||||
|
|
||||||
|
const coldMax = stats.getByStat('coldmaxdam')!
|
||||||
|
expect(coldMax.id).toBe(55)
|
||||||
|
expect(coldMax.saveBits).toBe(9)
|
||||||
|
expect(coldMax.saveAdd).toBe(0)
|
||||||
|
|
||||||
|
const coldLen = stats.getByStat('coldlength')!
|
||||||
|
expect(coldLen.id).toBe(56)
|
||||||
|
expect(coldLen.saveBits).toBe(8)
|
||||||
|
expect(coldLen.saveAdd).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies 268 properties parsed in Properties.txt (filtering out 1 Expansion row)', () => {
|
||||||
|
expect(properties.records).toHaveLength(268)
|
||||||
|
expect(properties.rows).toHaveLength(268)
|
||||||
|
expect(properties.byCode.size).toBe(268)
|
||||||
|
|
||||||
|
// Verify the separator row 'Expansion' was filtered out
|
||||||
|
expect(properties.get('Expansion')).toBeUndefined()
|
||||||
|
expect(properties.getByCode('Expansion')).toBeUndefined()
|
||||||
|
|
||||||
|
// Verify common property codes exist
|
||||||
|
expect(properties.get('ac')).toBeDefined()
|
||||||
|
expect(properties.get('str')).toBeDefined()
|
||||||
|
expect(properties.get('dex')).toBeDefined()
|
||||||
|
expect(properties.get('vit')).toBeDefined()
|
||||||
|
expect(properties.get('enr')).toBeDefined()
|
||||||
|
expect(properties.get('all-stats')).toBeDefined()
|
||||||
|
expect(properties.get('dmg-cold')).toBeDefined()
|
||||||
|
expect(properties.get('res-all')).toBeDefined()
|
||||||
|
expect(properties.get('sock')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies all non-empty stat entries in Properties map to a valid Stat in ItemStatCost', () => {
|
||||||
|
let checkedStats = 0
|
||||||
|
for (const prop of properties.records) {
|
||||||
|
for (const entry of prop.stats) {
|
||||||
|
if (entry.stat) {
|
||||||
|
const match = stats.getByStat(entry.stat)
|
||||||
|
expect(match, `Property ${prop.code} stat "${entry.stat}" must exist in ItemStatCost`).toBeDefined()
|
||||||
|
expect(match?.Stat).toBe(entry.stat)
|
||||||
|
checkedStats++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Diablo II Properties.txt has over 200 mapped stat definitions
|
||||||
|
expect(checkedStats).toBeGreaterThan(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tests property resolution for common properties (ac, str, dmg-cold, all-stats)', () => {
|
||||||
|
// 1. ac (armor class)
|
||||||
|
const acResolved = resolveProperty('ac', undefined, 10, 20)
|
||||||
|
expect(acResolved).toHaveLength(1)
|
||||||
|
expect(acResolved[0]).toMatchObject({
|
||||||
|
stat: 'armorclass',
|
||||||
|
statId: 31,
|
||||||
|
id: 31,
|
||||||
|
func: 1,
|
||||||
|
min: 10,
|
||||||
|
max: 20,
|
||||||
|
value: 10,
|
||||||
|
saveBits: 11,
|
||||||
|
saveAdd: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. str (strength)
|
||||||
|
const strResolved = resolveProperty('str', undefined, 15, 15)
|
||||||
|
expect(strResolved).toHaveLength(1)
|
||||||
|
expect(strResolved[0]).toMatchObject({
|
||||||
|
stat: 'strength',
|
||||||
|
statId: 0,
|
||||||
|
id: 0,
|
||||||
|
func: 1,
|
||||||
|
min: 15,
|
||||||
|
max: 15,
|
||||||
|
value: 15,
|
||||||
|
saveBits: 8,
|
||||||
|
saveAdd: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. dmg-cold (cold min damage, cold max damage, cold length)
|
||||||
|
// param = 75 (frames duration), min = 2, max = 4
|
||||||
|
const coldResolved = resolveProperty('dmg-cold', 75, 2, 4)
|
||||||
|
expect(coldResolved).toHaveLength(3)
|
||||||
|
|
||||||
|
// Slot 1: coldmindam (func 15 -> uses min field)
|
||||||
|
expect(coldResolved[0]).toMatchObject({
|
||||||
|
stat: 'coldmindam',
|
||||||
|
statId: 54,
|
||||||
|
id: 54,
|
||||||
|
func: 15,
|
||||||
|
min: 2,
|
||||||
|
max: 2,
|
||||||
|
value: 2,
|
||||||
|
param: 75,
|
||||||
|
saveBits: 8,
|
||||||
|
saveAdd: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Slot 2: coldmaxdam (func 16 -> uses max field)
|
||||||
|
expect(coldResolved[1]).toMatchObject({
|
||||||
|
stat: 'coldmaxdam',
|
||||||
|
statId: 55,
|
||||||
|
id: 55,
|
||||||
|
func: 16,
|
||||||
|
min: 4,
|
||||||
|
max: 4,
|
||||||
|
value: 4,
|
||||||
|
param: 75,
|
||||||
|
saveBits: 9,
|
||||||
|
saveAdd: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Slot 3: coldlength (func 17 -> uses param field)
|
||||||
|
expect(coldResolved[2]).toMatchObject({
|
||||||
|
stat: 'coldlength',
|
||||||
|
statId: 56,
|
||||||
|
id: 56,
|
||||||
|
func: 17,
|
||||||
|
min: 75,
|
||||||
|
max: 75,
|
||||||
|
value: 75,
|
||||||
|
param: 75,
|
||||||
|
saveBits: 8,
|
||||||
|
saveAdd: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. all-stats (strength, energy, dexterity, vitality)
|
||||||
|
// Func 1 for strength, Func 3 for others (copies min-max range from preceding slot)
|
||||||
|
const allStatsResolved = resolveProperty('all-stats', undefined, 5, 5)
|
||||||
|
expect(allStatsResolved).toHaveLength(4)
|
||||||
|
|
||||||
|
expect(allStatsResolved[0]).toMatchObject({
|
||||||
|
stat: 'strength',
|
||||||
|
statId: 0,
|
||||||
|
id: 0,
|
||||||
|
func: 1,
|
||||||
|
min: 5,
|
||||||
|
max: 5,
|
||||||
|
value: 5,
|
||||||
|
saveBits: 8,
|
||||||
|
saveAdd: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allStatsResolved[1]).toMatchObject({
|
||||||
|
stat: 'energy',
|
||||||
|
statId: 1,
|
||||||
|
id: 1,
|
||||||
|
func: 3,
|
||||||
|
min: 5,
|
||||||
|
max: 5,
|
||||||
|
value: 5,
|
||||||
|
saveBits: 7,
|
||||||
|
saveAdd: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allStatsResolved[2]).toMatchObject({
|
||||||
|
stat: 'dexterity',
|
||||||
|
statId: 2,
|
||||||
|
id: 2,
|
||||||
|
func: 3,
|
||||||
|
min: 5,
|
||||||
|
max: 5,
|
||||||
|
value: 5,
|
||||||
|
saveBits: 7,
|
||||||
|
saveAdd: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allStatsResolved[3]).toMatchObject({
|
||||||
|
stat: 'vitality',
|
||||||
|
statId: 3,
|
||||||
|
id: 3,
|
||||||
|
func: 3,
|
||||||
|
min: 5,
|
||||||
|
max: 5,
|
||||||
|
value: 5,
|
||||||
|
saveBits: 7,
|
||||||
|
saveAdd: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also test range on all-stats
|
||||||
|
const rangedAllStats = resolveProperty('all-stats', undefined, 10, 20)
|
||||||
|
expect(rangedAllStats).toHaveLength(4)
|
||||||
|
for (const s of rangedAllStats) {
|
||||||
|
expect(s.min).toBe(10)
|
||||||
|
expect(s.max).toBe(20)
|
||||||
|
expect(s.value).toBe(10)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies saveBits and saveAdd are accurately exposed for serialization', () => {
|
||||||
|
// Specifically test stats where 1.10+ values differ from old 1.09 values:
|
||||||
|
// 'strength': 1.09 had Save Bits = 7; 1.10+ has Save Bits = 8
|
||||||
|
const str = stats.getByStat('strength')!
|
||||||
|
expect(str.saveBits).toBe(8)
|
||||||
|
expect(str.saveAdd).toBe(32)
|
||||||
|
|
||||||
|
// 'fireresist': 1.09 had Save Add = 0; 1.10+ has Save Add = 50
|
||||||
|
const fireRes = stats.getByStat('fireresist')!
|
||||||
|
expect(fireRes.saveBits).toBe(8)
|
||||||
|
expect(fireRes.saveAdd).toBe(50)
|
||||||
|
|
||||||
|
// 'lightresist': Save Bits = 8, Save Add = 50
|
||||||
|
const lightRes = stats.getByStat('lightresist')!
|
||||||
|
expect(lightRes.saveBits).toBe(8)
|
||||||
|
expect(lightRes.saveAdd).toBe(50)
|
||||||
|
|
||||||
|
// 'coldresist': Save Bits = 8, Save Add = 50
|
||||||
|
const coldRes = stats.getByStat('coldresist')!
|
||||||
|
expect(coldRes.saveBits).toBe(8)
|
||||||
|
expect(coldRes.saveAdd).toBe(50)
|
||||||
|
|
||||||
|
// 'poisonresist': Save Bits = 8, Save Add = 50
|
||||||
|
const poisRes = stats.getByStat('poisonresist')!
|
||||||
|
expect(poisRes.saveBits).toBe(8)
|
||||||
|
expect(poisRes.saveAdd).toBe(50)
|
||||||
|
|
||||||
|
// 'item_fasterattackrate': Save Bits = 7, Save Add = 20
|
||||||
|
const ias = stats.getByStat('item_fasterattackrate')!
|
||||||
|
expect(ias.saveBits).toBe(7)
|
||||||
|
expect(ias.saveAdd).toBe(20)
|
||||||
|
|
||||||
|
// 'item_fastercastrate': Save Bits = 7, Save Add = 20
|
||||||
|
const fcr = stats.getByStat('item_fastercastrate')!
|
||||||
|
expect(fcr.saveBits).toBe(7)
|
||||||
|
expect(fcr.saveAdd).toBe(20)
|
||||||
|
|
||||||
|
// Verify propertiesTable.resolveProperty exposes accurate saveBits/saveAdd
|
||||||
|
const resAll = properties.resolveProperty('res-all', undefined, 15, 15)
|
||||||
|
expect(resAll).toHaveLength(4)
|
||||||
|
for (const r of resAll) {
|
||||||
|
expect(r.saveBits).toBe(8)
|
||||||
|
expect(r.saveAdd).toBe(50)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tests additional property resolutions: sock, class skills, and individual skill', () => {
|
||||||
|
// sock (sockets) -> item_numsockets
|
||||||
|
const sock = properties.resolveProperty('sock', undefined, 2, 4)
|
||||||
|
expect(sock).toHaveLength(1)
|
||||||
|
expect(sock[0].stat).toBe('item_numsockets')
|
||||||
|
expect(sock[0].func).toBe(14)
|
||||||
|
expect(sock[0].min).toBe(2)
|
||||||
|
expect(sock[0].max).toBe(4)
|
||||||
|
|
||||||
|
// ama (+ Amazon skills) -> item_addclassskills with val1 = 0
|
||||||
|
const ama = properties.resolveProperty('ama', undefined, 2, 2)
|
||||||
|
expect(ama).toHaveLength(1)
|
||||||
|
expect(ama[0].stat).toBe('item_addclassskills')
|
||||||
|
expect(ama[0].func).toBe(21)
|
||||||
|
expect(ama[0].param).toBe(0) // Amazon class ID
|
||||||
|
expect(ama[0].value).toBe(2)
|
||||||
|
|
||||||
|
// pal (+ Paladin skills) -> item_addclassskills with val1 = 3
|
||||||
|
const pal = properties.resolveProperty('pal', undefined, 1, 1)
|
||||||
|
expect(pal).toHaveLength(1)
|
||||||
|
expect(pal[0].stat).toBe('item_addclassskills')
|
||||||
|
expect(pal[0].func).toBe(21)
|
||||||
|
expect(pal[0].param).toBe(3) // Paladin class ID
|
||||||
|
expect(pal[0].value).toBe(1)
|
||||||
|
|
||||||
|
// skill (+ specific skill bonus) -> item_singleskill with skill ID in param
|
||||||
|
const skill = properties.resolveProperty('skill', 54, 1, 1) // 54 = Teleport
|
||||||
|
expect(skill).toHaveLength(1)
|
||||||
|
expect(skill[0].stat).toBe('item_singleskill')
|
||||||
|
expect(skill[0].func).toBe(22)
|
||||||
|
expect(skill[0].param).toBe(54)
|
||||||
|
expect(skill[0].value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue