From 20a8ff58f768884b082ea4b10fa6a95b2e9f2ed9 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:12:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=8A=A0=E8=BD=BD=20RarePrefix.?= =?UTF-8?q?txt=20=E4=B8=8E=20RareSuffix.txt=20(Issue=20#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/rare-names.ts | 305 ++++++++++++++++++++++++++++++++ tests/rare-names.test.ts | 368 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 673 insertions(+) create mode 100644 src/game/rare-names.ts create mode 100644 tests/rare-names.test.ts diff --git a/src/game/rare-names.ts b/src/game/rare-names.ts new file mode 100644 index 0000000..ffd79a4 --- /dev/null +++ b/src/game/rare-names.ts @@ -0,0 +1,305 @@ +/** + * Rare and crafted item name tables (`RarePrefix.txt` and `RareSuffix.txt`). + * + * In Diablo II, rare and crafted items derive their names from a combination of + * one prefix (rare name 1) and one suffix (rare name 2) chosen from these tables. + * Character save files (.d2s) store these two names as 8-bit unsigned integers + * (0..255), which directly index the rows of `RarePrefix.txt` and `RareSuffix.txt` + * in 0-based order. + * + * Unlike `MagicPrefix.txt` and `MagicSuffix.txt`, rare prefix and suffix tables + * DO NOT provide stat modifiers; they only provide the generated names and their + * item-type eligibility criteria (`itype1..7` and `etype1..4`). + * + * If all `itype1..7` fields are empty, the entry is a WILDCARD that can spawn on + * any item type (for example, 'Warp' and 'Rift'). If any `etype1..4` match the item's + * type, the entry is excluded. + */ +import type { MountedArchives } from '../mpq/mount.ts' +import { parseTable, cell } from './acts.ts' +import type { D2Table } from './acts.ts' + +/** Archive path for rare prefixes. */ +export const RARE_PREFIX_TABLE_PATH = 'data\\global\\excel\\RarePrefix.txt' + +/** Archive path for rare suffixes. */ +export const RARE_SUFFIX_TABLE_PATH = 'data\\global\\excel\\RareSuffix.txt' + +/** + * One parsed entry from `RarePrefix.txt` or `RareSuffix.txt`. + */ +export interface RareNameEntry { + /** 0-based index in the table, matching the 8-bit .d2s rare name index (0..255). */ + readonly index: number + /** Name string key / display name. */ + readonly name: string + /** Version: 0 for Classic, 100 for Expansion. */ + readonly version: number + /** Allowed item types (`itype1..7`). Empty array indicates a wildcard matching any item. */ + readonly itypes: readonly string[] + /** Excluded item types (`etype1..4`). */ + readonly etypes: readonly string[] + /** Cost modifier: add. */ + readonly add: number + /** Cost modifier: multiply. */ + readonly multiply: number + /** Cost modifier: divide. */ + readonly divide: number +} + +/** + * A parsed rare names table with lookup and filtering methods. + */ +export interface RareNameTable { + /** The underlying raw D2Table. */ + readonly table: D2Table + /** All parsed entries preserving 0-indexed table order. */ + readonly entries: readonly RareNameEntry[] + /** Number of entries in this table. */ + readonly length: number + /** Lookup entry by 0-based index. */ + readonly byIndex: (index: number) => RareNameEntry | undefined + /** Lookup entry by name (case-insensitive, first match). */ + readonly byName: (name: string) => RareNameEntry | undefined + /** Filter entries eligible for given item type code. */ + readonly getEligible: ( + itemTypeCode: string, + isA: (type: string, target: string) => boolean, + ) => readonly RareNameEntry[] + /** Allow iteration over entries. */ + [Symbol.iterator](): IterableIterator +} + +/** + * Test whether a rare name entry is eligible for an item type. + * + * Rules: + * - If itemTypeCode matches any `etypes` (via `isA(itemTypeCode, etype)`), it is excluded -> false. + * - If `itypes` is empty, it is a WILDCARD -> true. + * - Otherwise, it must match at least one `itype` (via `isA(itemTypeCode, itype)`) -> boolean. + * + * @param entry - The rare prefix or suffix entry. + * @param itemTypeCode - The 3-4 letter item type code (e.g. 'ring', 'swor'). + * @param isA - Item type inheritance tester: `(type, target) => boolean`. + * Defaults to case-insensitive equality if not specified. + * @returns true if the item type is eligible to roll this name. + */ +export function isRareNameEligible( + entry: RareNameEntry, + itemTypeCode: string, + isA: (type: string, target: string) => boolean = (type, target) => + type.toLowerCase() === target.toLowerCase(), +): boolean { + if (entry.etypes.some(etype => isA(itemTypeCode, etype))) { + return false + } + if (entry.itypes.length === 0) { + return true + } + return entry.itypes.some(itype => isA(itemTypeCode, itype)) +} + +/** + * Read a column from a D2Table row with case-insensitive column matching fallback. + */ +function getCell(table: D2Table, row: readonly string[], column: string): string { + const direct = cell(table, row, column) + if (direct !== '') return direct + const lower = column.toLowerCase() + const idx = table.header.findIndex(h => h.toLowerCase() === lower) + return idx === -1 ? '' : (row[idx] ?? '') +} + +/** + * Parse a D2Table into a RareNameTable. + * + * Preserves 0-indexed row order matching .d2s index values. + * + * @param table - Raw parsed D2Table. + * @returns Parsed RareNameTable. + */ +export function parseRareNamesTable(table: D2Table): RareNameTable { + const entries: RareNameEntry[] = [] + const nameMap = new Map() + + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]! + const name = getCell(table, row, 'name').trim() + const version = Number(getCell(table, row, 'version').trim()) || 0 + + const itypes: string[] = [] + for (let slot = 1; slot <= 7; slot++) { + const itype = getCell(table, row, `itype${slot}`).trim() + if (itype !== '') { + itypes.push(itype) + } + } + + const etypes: string[] = [] + for (let slot = 1; slot <= 4; slot++) { + const etype = getCell(table, row, `etype${slot}`).trim() + if (etype !== '') { + etypes.push(etype) + } + } + + const add = Number(getCell(table, row, 'add').trim()) || 0 + const multiply = Number(getCell(table, row, 'multiply').trim()) || 0 + const divide = Number(getCell(table, row, 'divide').trim()) || 0 + + const entry: RareNameEntry = { + index: i, + name, + version, + itypes, + etypes, + add, + multiply, + divide, + } + + entries.push(entry) + const lower = name.toLowerCase() + if (!nameMap.has(lower)) { + nameMap.set(lower, entry) + } + } + + const byIndex = (index: number): RareNameEntry | undefined => entries[index] + const byName = (name: string): RareNameEntry | undefined => nameMap.get(name.toLowerCase()) + const getEligible = ( + itemTypeCode: string, + isA: (type: string, target: string) => boolean, + ): readonly RareNameEntry[] => { + return entries.filter(entry => isRareNameEligible(entry, itemTypeCode, isA)) + } + + return { + table, + entries, + length: entries.length, + byIndex, + byName, + getEligible, + [Symbol.iterator](): IterableIterator { + return entries[Symbol.iterator]() + }, + } +} + +/** + * Load and parse both RarePrefix.txt and RareSuffix.txt from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @returns Object containing parsed prefixes and suffixes tables. + */ +export async function loadRareNames( + archives: MountedArchives, +): Promise<{ prefixes: RareNameTable; suffixes: RareNameTable }> { + const prefixTable = parseTable(await archives.read(RARE_PREFIX_TABLE_PATH)) + const suffixTable = parseTable(await archives.read(RARE_SUFFIX_TABLE_PATH)) + return { + prefixes: parseRareNamesTable(prefixTable), + suffixes: parseRareNamesTable(suffixTable), + } +} + +/** Canonical RarePrefix TSV extracted from 1.13c game data (d2exp.mpq). */ +export const CANONICAL_RARE_PREFIX_TSV = "name\tversion\titype1\titype2\titype3\titype4\titype5\titype6\titype7\tetype1\tetype2\tetype3\tetype4\tadd\tmultiply\tdivide\nBeast\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nEagle\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nRaven\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nViper\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nGhoulRI\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nSkull\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nBlood\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nDread\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nDoom\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nGrim\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nBone\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nDeath\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nShadow\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nStorm\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nRune\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nPlagueRI\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nStone\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nWraithra\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nSpirit\t0\tarmo\trod\t\t\t\t\t\t\t\t\t\t0\t0\t0\nStorm\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nDemon\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nCruel\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nEmpyrion\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nBramble\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nPain\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nLoath\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nGlyph\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nImp\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nFiendra\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nHailstone\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nGale\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nDire\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nSoul\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nBrimstone\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nCorpse\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nCarrion\t0\tarmo\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0\nHolocaust\t0\ttors\thelm\tshld\tswor\taxe\t\t\t\t\t\t\t0\t0\t0\nHavoc\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nBitter\t0\tarmo\tweap\tmisc\t\t\t\t\t\t\t\t\t0\t0\t0\nEntropy\t0\tring\tamul\t\t\t\t\t\t\t\t\t\t0\t0\t0\nChaos\t0\tring\tamul\t\t\t\t\t\t\t\t\t\t0\t0\t0\nOrder\t0\tring\tamul\tscep\t\t\t\t\t\t\t\t\t0\t0\t0\nRule\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nWarp\t0\t\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nRift\t0\t\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nCorruption\t0\tring\tamul\t\t\t\t\t\t\t\t\t\t0\t0\t0" + +/** Canonical RareSuffix TSV extracted from 1.13c game data (d2exp.mpq). */ +export const CANONICAL_RARE_SUFFIX_TSV = "name\tversion\titype1\titype2\titype3\titype4\titype5\titype6\titype7\tetype1\tetype2\tetype3\tetype4\tadd\tmultiply\tdivide\nbite\t0\tswor\tknif\tspea\tpole\taxe\th2h\t\t\t\t\t\t0\t0\t0\nscratch\t0\tswor\tknif\tspea\tpole\th2h\t\t\t\t\t\t\t0\t0\t0\nscalpel\t0\tswor\tknif\t\t\t\t\t\t\t\t\t\t0\t0\t0\nfang\t0\tswor\tknif\tspea\tpole\t\t\t\t\t\t\t\t0\t0\t0\ngutter\t0\tswor\tknif\tspea\tpole\t\t\t\t\t\t\t\t0\t0\t0\nthirst\t0\tswor\tknif\tspea\tpole\taxe\th2h\t\t\t\t\t\t0\t0\t0\nrazor\t0\tswor\tknif\taxe\th2h\t\t\t\t\t\t\t\t0\t0\t0\nscythe\t0\tswor\taxe\tpole\t\t\t\t\t\t\t\t\t0\t0\t0\nedge\t0\tswor\tknif\taxe\t\t\t\t\t\t\t\t\t0\t0\t0\nsaw\t0\tswor\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nsplitter\t0\taxe\tmace\tclub\thamm\t\t\t\t\t\t\t\t0\t0\t0\ncleaver\t0\tswor\taxe\t\t\t\t\t\t\t\t\t\t0\t0\t0\nsever\t0\tswor\taxe\t\t\t\t\t\t\t\t\t\t0\t0\t0\nsunder\t0\taxe\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nrend\t0\taxe\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nmangler\t0\taxe\tmace\tclub\thamm\t\t\t\t\t\t\t\t0\t0\t0\nslayer\t0\taxe\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nreaver\t0\taxe\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nspawn\t0\taxe\thamm\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngnash\t0\taxe\tclub\thamm\t\t\t\t\t\t\t\t\t0\t0\t0\nstar\t0\tmace\thamm\tscep\twand\t\t\t\t\t\t\t\t0\t0\t0\nblow\t0\tmace\tclub\thamm\tscep\t\t\t\t\t\t\t\t0\t0\t0\nsmasher\t0\tmace\tclub\thamm\tscep\t\t\t\t\t\t\t\t0\t0\t0\nBane\t0\tmace\tscep\twand\t\t\t\t\t\t\t\t\t0\t0\t0\ncrusher\t0\tmace\tclub\thamm\tscep\t\t\t\t\t\t\t\t0\t0\t0\nbreaker\t0\tmace\tclub\thamm\tscep\t\t\t\t\t\t\t\t0\t0\t0\ngrinder\t0\tmace\tclub\thamm\tscep\t\t\t\t\t\t\t\t0\t0\t0\ncrack\t0\tmace\tclub\thamm\tscep\twand\t\t\t\t\t\t\t0\t0\t0\nmallet\t0\thamm\tclub\t\t\t\t\t\t\t\t\t\t0\t0\t0\nknell\t0\tmace\tclub\tscep\twand\t\t\t\t\t\t\t\t0\t0\t0\nlance\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\nspike\t0\tswor\tknif\tspea\tpole\t\t\t\t\t\t\t\t0\t0\t0\nimpaler\t0\tswor\tknif\tspea\tpole\t\t\t\t\t\t\t\t0\t0\t0\nskewer\t0\tswor\tknif\tspea\t\t\t\t\t\t\t\t\t0\t0\t0\nprod\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\nscourge\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\nwand\t0\twand\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nwrack\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbarb\t0\tswor\tknif\taxe\tspea\tpole\th2h\t\t\t\t\t\t0\t0\t0\nneedle\t0\tswor\tknif\tspea\tmiss\t\t\t\t\t\t\t\t0\t0\t0\ndart\t0\tspea\tmiss\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbolt\t0\tmiss\tjave\t\t\t\t\t\t\t\t\t\t0\t0\t0\nquarrel\t0\tmiss\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nfletch\t0\tmiss\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nflight\t0\tmiss\tjave\t\t\t\t\t\t\t\t\t\t0\t0\t0\nnock\t0\tmiss\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nhorn\t0\thelm\tmiss\tknif\t\t\t\t\t\t\t\t\t0\t0\t0\nstinger\t0\tswor\tknif\tspea\tmiss\t\t\t\t\t\t\t\t0\t0\t0\nquill\t0\tknif\tmiss\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngoad\t0\tspea\tpole\tstaf\t\t\t\t\t\t\t\t\t0\t0\t0\nbranch\t0\tspea\tstaf\tbow\t\t\t\t\t\t\t\t\t0\t0\t0\nspire\t0\tstaf\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nsong\t0\tweap\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncall\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncry\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nspell\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nchant\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nweaver\t0\trod\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngnarl\t0\tclub\twand\tstaf\t\t\t\t\t\t\t\t\t0\t0\t0\nvisage\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncrest\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncirclet\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nveil\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nhood\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nmask\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbrow\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncasque\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nvisor\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncowl\t0\thelm\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nhide\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nPelt\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncarapace\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncoat\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nwrap\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nsuit\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncloak\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nshroud\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\njack\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nmantle\t0\ttors\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nguard\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbadge\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nrock\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\naegis\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nward\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ntower\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nshield\t0\tshld\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nwing\t0\tshld\tamul\t\t\t\t\t\t\t\t\t\t0\t0\t0\nmark\t0\tshld\tamul\t\t\t\t\t\t\t\t\t\t0\t0\t0\nemblem\t0\tshld\tamul\t\t\t\t\t\t\t\t\t\t0\t0\t0\nhand\t0\tglov\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nfist\t0\tglov\th2h\t\t\t\t\t\t\t\t\t\t0\t0\t0\nclaw\t0\tglov\th2h\t\t\t\t\t\t\t\t\t\t0\t0\t0\nclutches\t0\tglov\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngrip\t0\tglov\tring\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngrasp\t0\tglov\tring\t\t\t\t\t\t\t\t\t\t0\t0\t0\nhold\t0\tglov\tring\t\t\t\t\t\t\t\t\t\t0\t0\t0\ntouch\t0\tglov\tring\t\t\t\t\t\t\t\t\t\t0\t0\t0\nfinger\t0\tglov\tring\t\t\t\t\t\t\t\t\t\t0\t0\t0\nknuckle\t0\tglov\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nshank\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nspur\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ntread\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nstalker\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngreave\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nblazer\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nnails\t0\tboot\tspea\tpole\t\t\t\t\t\t\t\t\t0\t0\t0\ntrample\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nBrogues\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ntrack\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nslippers\t0\tboot\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nclasp\t0\tbelt\tamul\t\t\t\t\t\t\t\t\t\t1\t1\t1\nbuckle\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nharness\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nlock\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nfringe\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nwinding\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nchain\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nstrap\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nlash\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\ncord\t0\tbelt\t\t\t\t\t\t\t\t\t\t\t1\t1\t1\nknot\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncircle\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nloop\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\neye\t0\tmisc\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nturn\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nspiral\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncoil\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngyre\t0\tring\torb\tjewl\t\t\t\t\t\t\t\t\t0\t0\t0\nband\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nwhorl\t0\tring\torb\tjewl\t\t\t\t\t\t\t\t\t0\t0\t0\ntalisman\t0\tamul\tjewl\t\t\t\t\t\t\t\t\t\t0\t0\t0\nheart\t0\tamul\torb\tjewl\t\t\t\t\t\t\t\t\t0\t0\t0\nnoose\t0\tamul\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nnecklace\t0\tamul\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncollar\t0\tamul\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbeads\t0\tamul\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ntorc\t0\tamul\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ngorget\t0\tamul\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nscarab\t0\tamul\tjewl\t\t\t\t\t\t\t\t\t\t0\t0\t0\nwood\t0\tspea\tpole\twand\tstaf\t\t\t\t\t\t\t\t0\t0\t0\nbrand\t0\tblun\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbludgeon\t0\tblun\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\ncudgel\t0\tclub\twand\t\t\t\t\t\t\t\t\t\t0\t0\t0\nloom\t0\tmiss\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nharp\t0\tmiss\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nmaster\t0\tring\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nbarRI\t0\tblun\tspea\tpole\t\t\t\t\t\t\t\t\t0\t0\t0\nhew\t0\tswor\tknif\taxe\t\t\t\t\t\t\t\t\t0\t0\t0\ncrook\t0\tstaf\t\t\t\t\t\t\t\t\t\t\t0\t0\t0\nmar\t0\tswor\tknif\tmace\tclub\thamm\tspea\t\t\t\t\t\t0\t0\t0\nshell\t0\ttors\thelm\tshld\t\t\t\t\t\t\t\t\t0\t0\t0\nstake\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\npicket\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\npale\t0\tspea\tpole\t\t\t\t\t\t\t\t\t\t0\t0\t0\nflange\t0\ttors\tmace\thamm\tscep\t\t\t\t\t\t\t\t0\t0\t0" + +/** Canonical RarePrefix D2Table parsed from 1.13c game data. */ +export const CANONICAL_RARE_PREFIXES_TABLE: D2Table = parseTable( + new TextEncoder().encode(CANONICAL_RARE_PREFIX_TSV.trim()), +) + +/** Canonical RareSuffix D2Table parsed from 1.13c game data. */ +export const CANONICAL_RARE_SUFFIXES_TABLE: D2Table = parseTable( + new TextEncoder().encode(CANONICAL_RARE_SUFFIX_TSV.trim()), +) + +/** Canonical RarePrefix table with lookup and eligibility methods. */ +export const CANONICAL_RARE_PREFIXES: RareNameTable = parseRareNamesTable( + CANONICAL_RARE_PREFIXES_TABLE, +) + +/** Canonical RareSuffix table with lookup and eligibility methods. */ +export const CANONICAL_RARE_SUFFIXES: RareNameTable = parseRareNamesTable( + CANONICAL_RARE_SUFFIXES_TABLE, +) + +/** + * Get all rare prefixes eligible for a given item type. + * + * Supports calling with `(typeCode, isA, table?)` or `(table, typeCode, isA)`. + */ +export function getEligibleRarePrefixes( + typeCode: string, + isA: (type: string, target: string) => boolean, + table?: RareNameTable, +): readonly RareNameEntry[] +export function getEligibleRarePrefixes( + table: RareNameTable, + typeCode: string, + isA: (type: string, target: string) => boolean, +): readonly RareNameEntry[] +export function getEligibleRarePrefixes( + first: string | RareNameTable, + second: ((type: string, target: string) => boolean) | string, + third?: ((type: string, target: string) => boolean) | RareNameTable, +): readonly RareNameEntry[] { + let table: RareNameTable + let typeCode: string + let isA: (type: string, target: string) => boolean + + if (typeof first === 'string') { + typeCode = first + isA = second as (type: string, target: string) => boolean + table = (third as RareNameTable | undefined) ?? CANONICAL_RARE_PREFIXES + } else { + table = first + typeCode = second as string + isA = third as (type: string, target: string) => boolean + } + + return table.getEligible(typeCode, isA) +} + +/** + * Get all rare suffixes eligible for a given item type. + * + * Supports calling with `(typeCode, isA, table?)` or `(table, typeCode, isA)`. + */ +export function getEligibleRareSuffixes( + typeCode: string, + isA: (type: string, target: string) => boolean, + table?: RareNameTable, +): readonly RareNameEntry[] +export function getEligibleRareSuffixes( + table: RareNameTable, + typeCode: string, + isA: (type: string, target: string) => boolean, +): readonly RareNameEntry[] +export function getEligibleRareSuffixes( + first: string | RareNameTable, + second: ((type: string, target: string) => boolean) | string, + third?: ((type: string, target: string) => boolean) | RareNameTable, +): readonly RareNameEntry[] { + let table: RareNameTable + let typeCode: string + let isA: (type: string, target: string) => boolean + + if (typeof first === 'string') { + typeCode = first + isA = second as (type: string, target: string) => boolean + table = (third as RareNameTable | undefined) ?? CANONICAL_RARE_SUFFIXES + } else { + table = first + typeCode = second as string + isA = third as (type: string, target: string) => boolean + } + + return table.getEligible(typeCode, isA) +} diff --git a/tests/rare-names.test.ts b/tests/rare-names.test.ts new file mode 100644 index 0000000..a0ae745 --- /dev/null +++ b/tests/rare-names.test.ts @@ -0,0 +1,368 @@ +/** + * 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) + } + }) + }) +})