diablo2-web/tests/affixes.test.ts

328 lines
14 KiB
TypeScript

import { describe, expect, it, beforeAll } from 'vitest'
import * as fs from 'node:fs'
import {
parseMagicAffixesTable,
loadMagicAffixes,
isAffixEligible,
MAGIC_PREFIX_TABLE_PATH,
MAGIC_SUFFIX_TABLE_PATH,
type MagicAffix,
type AffixTable,
} from '../src/game/affixes.ts'
import { parseTable } from '../src/game/acts.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadItemTypes, isA, type ItemTypeTable } from '../src/game/item-types.ts'
const MPQ_DIR = 'samples/d2'
const hasMpqs = fs.existsSync(`${MPQ_DIR}/d2exp.mpq`)
describe('MagicPrefix & MagicSuffix Standalone / Synthetic Parsing', () => {
it('parses synthetic prefix TSV with 41 columns and etype1..5', () => {
const syntheticPrefixTsv = [
'Name\tversion\tspawnable\trare\tlevel\tmaxlevel\tlevelreq\tclassspecific\tclass\tclasslevelreq\tfrequency\tgroup\tmod1code\tmod1param\tmod1min\tmod1max\tmod2code\tmod2param\tmod2min\tmod2max\tmod3code\tmod3param\tmod3min\tmod3max\ttransform\ttransformcolor\titype1\titype2\titype3\titype4\titype5\titype6\titype7\tetype1\tetype2\tetype3\tetype4\tetype5\tdivide\tmultiply\tadd',
'Sturdy\t0\t1\t1\t4\t\t3\t\t\t\t0\t101\tac%\t\t20\t30\t\t\t\t\t\t\t\t\t\t\tarmo\t\t\t\t\t\t\t\t\t\t\t\t0\t0\t0',
'Expansion\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t',
'\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t',
'Knight\'s\t100\t1\t1\t50\t\t42\t\t\t\t2\t104\tatt\t\t101\t121\tdmg%\t\t51\t65\t\t\t\t\t1\tdgld\tarmo\tweap\t\t\t\t\t\t\t\t\t\t\t0\t0\t0',
'Unspawnable\t100\t0\t1\t10\t\t5\t\t\t\t1\t999\thp\t\t5\t10\t\t\t\t\t\t\t\t\t\t\tarmo\t\t\t\t\t\t\t\t\t\t\t\t0\t0\t0',
].join('\n')
const table = parseMagicAffixesTable(syntheticPrefixTsv, true)
expect(table.isPrefix).toBe(true)
// Real rows: Sturdy, Knight's, Unspawnable (Expansion and empty Name filtered)
expect(table.length).toBe(3)
expect(table.all).toHaveLength(3)
const sturdy = table.all[0]!
expect(sturdy.name).toBe('Sturdy')
expect(sturdy.isPrefix).toBe(true)
expect(sturdy.spawnable).toBe(true)
expect(sturdy.rare).toBe(true)
expect(sturdy.level).toBe(4)
expect(sturdy.maxlevel).toBe(0)
expect(sturdy.levelreq).toBe(3)
expect(sturdy.group).toBe(101)
expect(sturdy.mods).toEqual([{ code: 'ac%', min: 20, max: 30 }])
expect(sturdy.itypes).toEqual(['armo'])
expect(sturdy.etypes).toEqual([])
const knights = table.all[1]!
expect(knights.name).toBe("Knight's")
expect(knights.transform).toBe(true)
expect(knights.transformColor).toBe('dgld')
expect(knights.mods).toEqual([
{ code: 'att', min: 101, max: 121 },
{ code: 'dmg%', min: 51, max: 65 },
])
expect(knights.itypes).toEqual(['armo', 'weap'])
// Lookups
expect(table.byName.get('Sturdy')).toEqual([sturdy])
expect(table.getByName("Knight's")).toEqual([knights])
expect(table.getByGroup(101)).toEqual([sturdy])
expect(table.getByGroup(104)).toEqual([knights])
})
it('parses synthetic suffix TSV with 39 columns and etype1..3', () => {
const syntheticSuffixTsv = [
'Name\tversion\tspawnable\trare\tlevel\tmaxlevel\tlevelreq\tclassspecific\tclass\tclasslevelreq\tfrequency\tgroup\tmod1code\tmod1param\tmod1min\tmod1max\tmod2code\tmod2param\tmod2min\tmod2max\tmod3code\tmod3param\tmod3min\tmod3max\ttransform\ttransformcolor\titype1\titype2\titype3\titype4\titype5\titype6\titype7\tetype1\tetype2\tetype3\tdivide\tmultiply\tadd',
'of the Ox\t0\t1\t1\t18\t\t13\t\t\t\t0\t34\tstr\t\t7\t10\t\t\t\t\t\t\t\t\t\t\tmele\tbelt\tring\tamul\t\t\t\twand\tstaf\t\t0\t0\t0',
'Expansion\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t',
'of the Ox\t1\t1\t1\t26\t\t19\t\t\t\t3\t31\tstr\t\t6\t9\t\t\t\t\t\t\t\t\t\t\tamul\tbelt\tclub\thamm\tcirc\t\t\t\t\t\t0\t0\t0',
].join('\n')
const table = parseMagicAffixesTable(syntheticSuffixTsv, false)
expect(table.isPrefix).toBe(false)
expect(table.length).toBe(2)
const oxList = table.getByName('of the Ox')
expect(oxList).toHaveLength(2)
const oxClassic = oxList[0]!
expect(oxClassic.name).toBe('of the Ox')
expect(oxClassic.isPrefix).toBe(false)
expect(oxClassic.group).toBe(34)
expect(oxClassic.level).toBe(18)
expect(oxClassic.mods).toEqual([{ code: 'str', min: 7, max: 10 }])
expect(oxClassic.itypes).toEqual(['mele', 'belt', 'ring', 'amul'])
expect(oxClassic.etypes).toEqual(['wand', 'staf'])
const oxLod = oxList[1]!
expect(oxLod.group).toBe(31)
expect(oxLod.level).toBe(26)
expect(oxLod.mods).toEqual([{ code: 'str', min: 6, max: 9 }])
})
})
describe('isAffixEligible helper with mock isA', () => {
const mockIsA = (type: string, target: string): boolean => {
if (type === target) return true
if (type === 'swor' && target === 'mele') return true
if (type === 'wand' && target === 'mele') return true
if (type === 'staf' && target === 'mele') return true
if (type === 'tors' && target === 'armo') return true
if (type === 'helm' && target === 'armo') return true
return false
}
const sampleAffix: MagicAffix = {
name: 'Sample',
isPrefix: true,
version: 100,
spawnable: true,
rare: false,
level: 20,
maxlevel: 50,
levelreq: 15,
frequency: 5,
group: 1,
mods: [{ code: 'hp', min: 10, max: 20 }],
transform: false,
itypes: ['mele', 'armo'],
etypes: ['wand'],
}
it('rejects unspawnable affixes', () => {
const unspawnable = { ...sampleAffix, spawnable: false }
expect(isAffixEligible(unspawnable, 'swor', mockIsA, { alvl: 30 })).toBe(false)
})
it('enforces rare flag when options.isRare is true', () => {
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 30, isRare: true })).toBe(false)
const rareAffix = { ...sampleAffix, rare: true }
expect(isAffixEligible(rareAffix, 'swor', mockIsA, { alvl: 30, isRare: true })).toBe(true)
// When isRare is false or undefined, non-rare affixes can spawn
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 30, isRare: false })).toBe(true)
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 30 })).toBe(true)
})
it('enforces level requirements and maxlevel constraints', () => {
// level: 20, maxlevel: 50
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 19 })).toBe(false)
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 20 })).toBe(true)
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 50 })).toBe(true)
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 51 })).toBe(false)
// maxlevel === 0 means no upper limit
const uncapped = { ...sampleAffix, maxlevel: 0 }
expect(isAffixEligible(uncapped, 'swor', mockIsA, { alvl: 99 })).toBe(true)
})
it('enforces etype exclusion blacklist before itype whitelist', () => {
// wand is mele, but wand is in etypes: ['wand']
expect(isAffixEligible(sampleAffix, 'wand', mockIsA, { alvl: 30 })).toBe(false)
// swor is mele, and not wand -> eligible
expect(isAffixEligible(sampleAffix, 'swor', mockIsA, { alvl: 30 })).toBe(true)
// staf is mele, and not wand -> eligible
expect(isAffixEligible(sampleAffix, 'staf', mockIsA, { alvl: 30 })).toBe(true)
})
it('enforces itype whitelist: item must match at least one itype', () => {
// tors matches armo
expect(isAffixEligible(sampleAffix, 'tors', mockIsA, { alvl: 30 })).toBe(true)
// ring does not match mele or armo
expect(isAffixEligible(sampleAffix, 'ring', mockIsA, { alvl: 30 })).toBe(false)
// affix with no itypes cannot match any item
const noItypes = { ...sampleAffix, itypes: [] }
expect(isAffixEligible(noItypes, 'swor', mockIsA, { alvl: 30 })).toBe(false)
})
it('enforces classspecific restriction against explicit options.itemClass and inferred itemTypeCode class', () => {
const classIsA = (type: string, target: string): boolean => {
if (type === target) return true
if ((type === 'swor' || type === 'abow' || type === 'orb') && target === 'weap') return true
if (type === 'abow' && target === 'amaz') return true
if (type === 'orb' && target === 'sorc') return true
return false
}
const amaSpecificAffix: MagicAffix = {
...sampleAffix,
name: 'Maiden\'s',
classspecific: 'ama',
class: 'ama',
levelreq: 36,
classlevelreq: 27,
itypes: ['weap'],
etypes: [],
}
// Generic sword ('swor'): must be false (both inferred and explicit itemClass: '')
expect(isAffixEligible(amaSpecificAffix, 'swor', classIsA, { alvl: 30 })).toBe(false)
expect(isAffixEligible(amaSpecificAffix, 'swor', classIsA, { alvl: 30, itemClass: '' })).toBe(false)
// Sorceress orb ('orb' / itemClass: 'sor'): must be false
expect(isAffixEligible(amaSpecificAffix, 'orb', classIsA, { alvl: 30 })).toBe(false)
expect(isAffixEligible(amaSpecificAffix, 'orb', classIsA, { alvl: 30, itemClass: 'sor' })).toBe(false)
// Amazon bow ('abow' / itemClass: 'ama'): must be true
expect(isAffixEligible(amaSpecificAffix, 'abow', classIsA, { alvl: 30 })).toBe(true)
expect(isAffixEligible(amaSpecificAffix, 'abow', classIsA, { alvl: 30, itemClass: 'ama' })).toBe(true)
})
})
describe('Magic Affixes Integration with 1.13c MPQ Archives', () => {
let prefixes: AffixTable
let suffixes: AffixTable
let itemTypes: ItemTypeTable
let isAPred: (type: string, target: string) => boolean
beforeAll(async () => {
if (!hasMpqs) return
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
const p = `${MPQ_DIR}/${name}`
if (fs.existsSync(p)) {
archives.add(name, await MpqArchive.open(await fileSource(p)))
}
}
const affixData = await loadMagicAffixes(archives)
prefixes = affixData.prefixes
suffixes = affixData.suffixes
itemTypes = await loadItemTypes(archives)
isAPred = (type, target) => isA(type, target, itemTypes)
})
it('confirms canonical table paths', () => {
expect(MAGIC_PREFIX_TABLE_PATH).toBe('data\\global\\excel\\MagicPrefix.txt')
expect(MAGIC_SUFFIX_TABLE_PATH).toBe('data\\global\\excel\\MagicSuffix.txt')
})
it.runIf(hasMpqs)('parses exactly 637 prefixes and 741 suffixes (total 1378)', () => {
expect(prefixes.length).toBe(637)
expect(prefixes.all.length).toBe(637)
expect(suffixes.length).toBe(741)
expect(suffixes.all.length).toBe(741)
expect(prefixes.length + suffixes.length).toBe(1378)
})
it.runIf(hasMpqs)('verifies spawnable=1 count: 587 prefixes, 575 suffixes (total 1162)', () => {
const spawnablePrefixes = prefixes.all.filter(a => a.spawnable)
const spawnableSuffixes = suffixes.all.filter(a => a.spawnable)
expect(spawnablePrefixes.length).toBe(587)
expect(spawnableSuffixes.length).toBe(575)
expect(spawnablePrefixes.length + spawnableSuffixes.length).toBe(1162)
})
it.runIf(hasMpqs)('sample check: Sturdy (group 101, ac% 20-30, itype=armo)', () => {
const sturdyList = prefixes.getByName('Sturdy')
expect(sturdyList.length).toBeGreaterThanOrEqual(1)
// Check classic version 0 entry: group 101, ac% 20-30, itypes: ['armo']
const sturdyV0 = sturdyList.find(a => a.version === 0)
expect(sturdyV0).toBeDefined()
expect(sturdyV0?.group).toBe(101)
expect(sturdyV0?.spawnable).toBe(true)
expect(sturdyV0?.rare).toBe(true)
expect(sturdyV0?.itypes).toEqual(['armo'])
expect(sturdyV0?.etypes).toEqual([])
expect(sturdyV0?.mods).toEqual([{ code: 'ac%', min: 20, max: 30 }])
// Test eligibility with loaded itemTypes hierarchy
// 'tors' (body armor) and 'shld' (shield) inherit from 'armo'
expect(isAffixEligible(sturdyV0!, 'tors', isAPred, { alvl: 10 })).toBe(true)
expect(isAffixEligible(sturdyV0!, 'shld', isAPred, { alvl: 10 })).toBe(true)
// Weapons do not inherit from armo
expect(isAffixEligible(sturdyV0!, 'swor', isAPred, { alvl: 10 })).toBe(false)
// Level requirement check: level is 4, so alvl 2 must fail
expect(isAffixEligible(sturdyV0!, 'tors', isAPred, { alvl: 2 })).toBe(false)
})
it.runIf(hasMpqs)('sample check: of the Ox (group 34, str 7-10, etype includes wand/staf)', () => {
const oxList = suffixes.getByName('of the Ox')
expect(oxList.length).toBeGreaterThanOrEqual(2)
// Group 34 classic entry
const oxGrp34 = oxList.find(a => a.group === 34 && a.etypes.includes('wand'))
expect(oxGrp34).toBeDefined()
expect(oxGrp34?.group).toBe(34)
expect(oxGrp34?.spawnable).toBe(true)
expect(oxGrp34?.level).toBe(18)
expect(oxGrp34?.mods).toEqual([{ code: 'str', min: 7, max: 10 }])
expect(oxGrp34?.itypes).toEqual(['mele', 'belt', 'ring', 'amul'])
expect(oxGrp34?.etypes).toContain('wand')
expect(oxGrp34?.etypes).toContain('staf')
// Test eligibility:
// 'swor' is mele, and is not wand or staf -> eligible
expect(isAffixEligible(oxGrp34!, 'swor', isAPred, { alvl: 20 })).toBe(true)
// 'wand' and 'staf' are melee weapons, but are blacklisted via etypes
expect(isAffixEligible(oxGrp34!, 'wand', isAPred, { alvl: 20 })).toBe(false)
expect(isAffixEligible(oxGrp34!, 'staf', isAPred, { alvl: 20 })).toBe(false)
// 'ring' is in itypes directly -> eligible
expect(isAffixEligible(oxGrp34!, 'ring', isAPred, { alvl: 20 })).toBe(true)
// alvl below level 18 -> false
expect(isAffixEligible(oxGrp34!, 'swor', isAPred, { alvl: 15 })).toBe(false)
})
it.runIf(hasMpqs)('verifies AffixTable indexing and iteration', () => {
// Iterable
const allPrefixes = [...prefixes]
expect(allPrefixes.length).toBe(637)
// byGroup
const group101 = prefixes.getByGroup(101)
expect(group101.length).toBeGreaterThan(0)
for (const affix of group101) {
expect(affix.group).toBe(101)
}
// getEligible on table
const eligibleForArmor = prefixes.getEligible('tors', isAPred, { alvl: 5 })
expect(eligibleForArmor.length).toBeGreaterThan(0)
for (const affix of eligibleForArmor) {
expect(affix.spawnable).toBe(true)
expect(affix.level).toBeLessThanOrEqual(5)
}
})
})