diff --git a/src/game/properties.ts b/src/game/properties.ts new file mode 100644 index 0000000..cc7ad76 --- /dev/null +++ b/src/game/properties.ts @@ -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 + /** Lookup map by numeric stat ID (0..358). */ + readonly byId: ReadonlyMap + /** 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 + /** 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() + const byId = new Map() + + 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() + + 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 +} diff --git a/tests/properties.test.ts b/tests/properties.test.ts new file mode 100644 index 0000000..abf8ec5 --- /dev/null +++ b/tests/properties.test.ts @@ -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) + }) +})