447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|