/** * Unit tests for rare prefix and suffix tables (RarePrefix.txt & RareSuffix.txt). * * Gold standard reference: 1.13c MPQ data\\global\\excel\\RarePrefix.txt (46 rows) * and RareSuffix.txt (155 rows) from d2exp.mpq. */ import { describe, it, expect, 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 { loadRareNames, parseRareNamesTable, isRareNameEligible, getEligibleRarePrefixes, getEligibleRareSuffixes, CANONICAL_RARE_PREFIXES, CANONICAL_RARE_SUFFIXES, CANONICAL_RARE_PREFIXES_TABLE, CANONICAL_RARE_SUFFIXES_TABLE, RARE_PREFIX_TABLE_PATH, RARE_SUFFIX_TABLE_PATH, } from '../src/game/rare-names.ts' import type { RareNameEntry, RareNameTable } from '../src/game/rare-names.ts' import { parseTable } from '../src/game/acts.ts' import type { D2Table } from '../src/game/acts.ts' const hasD2 = fs.existsSync('samples/d2/d2exp.mpq') describe('Rare Names Tables (Issue #95)', () => { describe('Row and column counts', () => { it('verifies 46 prefixes and 155 suffixes are parsed from canonical tables', () => { expect(CANONICAL_RARE_PREFIXES.entries.length).toBe(46) expect(CANONICAL_RARE_PREFIXES.length).toBe(46) expect(CANONICAL_RARE_PREFIXES_TABLE.rows.length).toBe(46) expect(CANONICAL_RARE_SUFFIXES.entries.length).toBe(155) expect(CANONICAL_RARE_SUFFIXES.length).toBe(155) expect(CANONICAL_RARE_SUFFIXES_TABLE.rows.length).toBe(155) }) it('verifies 16 columns in table header', () => { const expectedColumns = [ 'name', 'version', 'itype1', 'itype2', 'itype3', 'itype4', 'itype5', 'itype6', 'itype7', 'etype1', 'etype2', 'etype3', 'etype4', 'add', 'multiply', 'divide', ] expect(CANONICAL_RARE_PREFIXES_TABLE.header).toEqual(expectedColumns) expect(CANONICAL_RARE_SUFFIXES_TABLE.header).toEqual(expectedColumns) }) }) describe('8-bit index preservation (.d2s character save alignment)', () => { it('verifies all prefix indices fit in 8 bits (0..255) and match 0-based row order', () => { expect(CANONICAL_RARE_PREFIXES.entries.length).toBeLessThanOrEqual(256) CANONICAL_RARE_PREFIXES.entries.forEach((entry, idx) => { expect(entry.index).toBe(idx) expect(entry.index).toBeGreaterThanOrEqual(0) expect(entry.index).toBeLessThanOrEqual(255) expect(CANONICAL_RARE_PREFIXES.byIndex(idx)).toBe(entry) }) // Spot check specific known prefixes expect(CANONICAL_RARE_PREFIXES.entries[0]?.name).toBe('Beast') expect(CANONICAL_RARE_PREFIXES.entries[43]?.name).toBe('Warp') expect(CANONICAL_RARE_PREFIXES.entries[44]?.name).toBe('Rift') expect(CANONICAL_RARE_PREFIXES.entries[45]?.name).toBe('Corruption') }) it('verifies all suffix indices fit in 8 bits (0..255) and match 0-based row order', () => { expect(CANONICAL_RARE_SUFFIXES.entries.length).toBeLessThanOrEqual(256) CANONICAL_RARE_SUFFIXES.entries.forEach((entry, idx) => { expect(entry.index).toBe(idx) expect(entry.index).toBeGreaterThanOrEqual(0) expect(entry.index).toBeLessThanOrEqual(255) expect(CANONICAL_RARE_SUFFIXES.byIndex(idx)).toBe(entry) }) // Spot check specific known suffixes expect(CANONICAL_RARE_SUFFIXES.entries[0]?.name).toBe('bite') expect(CANONICAL_RARE_SUFFIXES.entries[1]?.name).toBe('scratch') expect(CANONICAL_RARE_SUFFIXES.entries[154]?.name).toBe('flange') }) }) describe('Wildcard items (empty itypes match any type)', () => { it('verifies Warp (index 43) and Rift (index 44) have empty itypes', () => { const warp = CANONICAL_RARE_PREFIXES.byIndex(43)! expect(warp).toBeDefined() expect(warp.name).toBe('Warp') expect(warp.itypes).toEqual([]) expect(warp.etypes).toEqual([]) const rift = CANONICAL_RARE_PREFIXES.byIndex(44)! expect(rift).toBeDefined() expect(rift.name).toBe('Rift') expect(rift.itypes).toEqual([]) expect(rift.etypes).toEqual([]) }) it('verifies wildcard entries match any item type', () => { const warp = CANONICAL_RARE_PREFIXES.byIndex(43)! const testTypes = ['ring', 'amul', 'swor', 'helm', 'boot', 'tors', 'shld', 'wand', 'bow', 'custom_type'] for (const typeCode of testTypes) { expect(isRareNameEligible(warp, typeCode)).toBe(true) } const rift = CANONICAL_RARE_PREFIXES.byIndex(44)! for (const typeCode of testTypes) { expect(isRareNameEligible(rift, typeCode)).toBe(true) } }) it('verifies synthetic wildcard entry matches everything unless excluded', () => { const wildcardWithExclusion: RareNameEntry = { index: 0, name: 'Wild', version: 0, itypes: [], etypes: ['bow'], add: 0, multiply: 0, divide: 0, } expect(isRareNameEligible(wildcardWithExclusion, 'swor')).toBe(true) expect(isRareNameEligible(wildcardWithExclusion, 'ring')).toBe(true) expect(isRareNameEligible(wildcardWithExclusion, 'bow')).toBe(false) }) }) describe('Specific items (e.g. itype1=ring, itype2=amul)', () => { it('verifies jewelry-only prefixes match only rings and amulets', () => { // In RarePrefix.txt: // Row 39: Entropy (ring, amul) // Row 40: Chaos (ring, amul) // Row 45: Corruption (ring, amul) const jewelryPrefixes = [ CANONICAL_RARE_PREFIXES.byIndex(39)!, CANONICAL_RARE_PREFIXES.byIndex(40)!, CANONICAL_RARE_PREFIXES.byIndex(45)!, ] for (const entry of jewelryPrefixes) { expect(entry.itypes).toEqual(['ring', 'amul']) expect(entry.etypes).toEqual([]) // Must match ring and amul expect(isRareNameEligible(entry, 'ring')).toBe(true) expect(isRareNameEligible(entry, 'amul')).toBe(true) // Must NOT match other item types expect(isRareNameEligible(entry, 'swor')).toBe(false) expect(isRareNameEligible(entry, 'helm')).toBe(false) expect(isRareNameEligible(entry, 'tors')).toBe(false) expect(isRareNameEligible(entry, 'boot')).toBe(false) expect(isRareNameEligible(entry, 'weap')).toBe(false) expect(isRareNameEligible(entry, 'armo')).toBe(false) } }) it('verifies weapon-only or rod-only prefixes match their respective types', () => { // Row 22: Empyrion (rod) const empyrion = CANONICAL_RARE_PREFIXES.byIndex(22)! expect(empyrion.name).toBe('Empyrion') expect(empyrion.itypes).toEqual(['rod']) expect(isRareNameEligible(empyrion, 'rod')).toBe(true) expect(isRareNameEligible(empyrion, 'ring')).toBe(false) expect(isRareNameEligible(empyrion, 'swor')).toBe(false) // Row 42: Rule (rod) const rule = CANONICAL_RARE_PREFIXES.byIndex(42)! expect(rule.name).toBe('Rule') expect(rule.itypes).toEqual(['rod']) expect(isRareNameEligible(rule, 'rod')).toBe(true) expect(isRareNameEligible(rule, 'ring')).toBe(false) // Row 41: Order (ring, amul, scep) const order = CANONICAL_RARE_PREFIXES.byIndex(41)! expect(order.name).toBe('Order') expect(order.itypes).toEqual(['ring', 'amul', 'scep']) expect(isRareNameEligible(order, 'ring')).toBe(true) expect(isRareNameEligible(order, 'amul')).toBe(true) expect(isRareNameEligible(order, 'scep')).toBe(true) expect(isRareNameEligible(order, 'swor')).toBe(false) expect(isRareNameEligible(order, 'helm')).toBe(false) }) }) describe('Item type inheritance with isA', () => { it('uses isA callback to resolve type hierarchy', () => { // Beast: armo, weap, misc const beast = CANONICAL_RARE_PREFIXES.byIndex(0)! // A hierarchy where sword is a weapon and crystal sword is a sword const isA = (type: string, target: string): boolean => { if (type === target) return true if (type === 'swor' && target === 'weap') return true if (type === 'crs' && target === 'swor') return true if (type === 'crs' && target === 'weap') return true return false } expect(isRareNameEligible(beast, 'weap', isA)).toBe(true) expect(isRareNameEligible(beast, 'swor', isA)).toBe(true) expect(isRareNameEligible(beast, 'crs', isA)).toBe(true) expect(isRareNameEligible(beast, 'ring', isA)).toBe(false) }) it('respects etype exclusions with isA', () => { const weaponNotBow: RareNameEntry = { index: 0, name: 'MeleeOnly', version: 0, itypes: ['weap'], etypes: ['bow'], add: 0, multiply: 0, divide: 0, } const isA = (type: string, target: string): boolean => { if (type === target) return true if (type === 'swor' && target === 'weap') return true if (type === 'bow' && target === 'weap') return true return false } expect(isRareNameEligible(weaponNotBow, 'swor', isA)).toBe(true) // bow is a weapon, but matches excluded etype 'bow' expect(isRareNameEligible(weaponNotBow, 'bow', isA)).toBe(false) }) }) describe('getEligibleRarePrefixes & getEligibleRareSuffixes', () => { it('returns eligible prefixes for ring', () => { const ringPrefixes = getEligibleRarePrefixes('ring', (t, target) => t === target) const names = ringPrefixes.map(p => p.name) // Must include jewelry prefixes expect(names).toContain('Entropy') expect(names).toContain('Chaos') expect(names).toContain('Corruption') expect(names).toContain('Order') // Must include wildcards expect(names).toContain('Warp') expect(names).toContain('Rift') // Must NOT include armor/weapon-only prefixes like Bramble or Holocaust expect(names).not.toContain('Bramble') expect(names).not.toContain('Holocaust') }) it('supports calling getEligibleRarePrefixes with table first or table third', () => { const ringPrefixes1 = getEligibleRarePrefixes(CANONICAL_RARE_PREFIXES, 'ring', (t, target) => t === target) const ringPrefixes2 = getEligibleRarePrefixes('ring', (t, target) => t === target, CANONICAL_RARE_PREFIXES) const ringPrefixes3 = getEligibleRarePrefixes('ring', (t, target) => t === target) expect(ringPrefixes1.map(p => p.name)).toEqual(ringPrefixes2.map(p => p.name)) expect(ringPrefixes1.map(p => p.name)).toEqual(ringPrefixes3.map(p => p.name)) }) it('returns eligible suffixes for swor', () => { const sworSuffixes = getEligibleRareSuffixes('swor', (t, target) => t === target) const names = sworSuffixes.map(s => s.name) // Suffix 0 is 'bite', which allows swor, knif, spea, pole, axe, h2h expect(names).toContain('bite') expect(names).toContain('scratch') expect(names).toContain('scalpel') // Suffix 151 is 'stake' which allows spea, pole (not swor) expect(names).not.toContain('stake') }) it('supports calling getEligibleRareSuffixes with table first or table third', () => { const sworSuffixes1 = getEligibleRareSuffixes(CANONICAL_RARE_SUFFIXES, 'swor', (t, target) => t === target) const sworSuffixes2 = getEligibleRareSuffixes('swor', (t, target) => t === target, CANONICAL_RARE_SUFFIXES) const sworSuffixes3 = getEligibleRareSuffixes('swor', (t, target) => t === target) expect(sworSuffixes1.map(s => s.name)).toEqual(sworSuffixes2.map(s => s.name)) expect(sworSuffixes1.map(s => s.name)).toEqual(sworSuffixes3.map(s => s.name)) }) }) describe('Lookup helpers and iterable support', () => { it('looks up entries by name case-insensitively', () => { expect(CANONICAL_RARE_PREFIXES.byName('warp')?.name).toBe('Warp') expect(CANONICAL_RARE_PREFIXES.byName('WARP')?.name).toBe('Warp') expect(CANONICAL_RARE_PREFIXES.byName('Rift')?.index).toBe(44) expect(CANONICAL_RARE_PREFIXES.byName('nonexistent')).toBeUndefined() expect(CANONICAL_RARE_SUFFIXES.byName('BITE')?.name).toBe('bite') expect(CANONICAL_RARE_SUFFIXES.byName('Flange')?.index).toBe(154) }) it('supports iterating over RareNameTable', () => { let count = 0 for (const entry of CANONICAL_RARE_PREFIXES) { expect(entry.name).toBeTruthy() count++ } expect(count).toBe(46) }) }) describe.skipIf(!hasD2)('Real MPQ loading via loadRareNames (1.13c d2exp.mpq)', () => { let archives: MountedArchives let loaded: { prefixes: RareNameTable; suffixes: RareNameTable } 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}`))) } loaded = await loadRareNames(archives) }) it('loads 46 prefixes and 155 suffixes from MPQ archives', () => { expect(loaded.prefixes.entries.length).toBe(46) expect(loaded.suffixes.entries.length).toBe(155) }) it('matches the canonical table data exactly row for row', () => { for (let i = 0; i < 46; i++) { const fromMpq = loaded.prefixes.entries[i]! const canonical = CANONICAL_RARE_PREFIXES.entries[i]! expect(fromMpq.name).toBe(canonical.name) expect(fromMpq.version).toBe(canonical.version) expect(fromMpq.itypes).toEqual(canonical.itypes) expect(fromMpq.etypes).toEqual(canonical.etypes) expect(fromMpq.add).toBe(canonical.add) expect(fromMpq.multiply).toBe(canonical.multiply) expect(fromMpq.divide).toBe(canonical.divide) } for (let i = 0; i < 155; i++) { const fromMpq = loaded.suffixes.entries[i]! const canonical = CANONICAL_RARE_SUFFIXES.entries[i]! expect(fromMpq.name).toBe(canonical.name) expect(fromMpq.version).toBe(canonical.version) expect(fromMpq.itypes).toEqual(canonical.itypes) expect(fromMpq.etypes).toEqual(canonical.etypes) expect(fromMpq.add).toBe(canonical.add) expect(fromMpq.multiply).toBe(canonical.multiply) expect(fromMpq.divide).toBe(canonical.divide) } }) }) })