From 866cdd4988d3cc24fe26466c9f6b4edb60cd8d99 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:28:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=8A=A0=E8=BD=BD=20MagicPrefix?= =?UTF-8?q?.txt=20=E4=B8=8E=20MagicSuffix.txt=20(Issue=20#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/affixes.ts | 410 ++++++++++++++++++++++++++++++++++++++++++ tests/affixes.test.ts | 294 ++++++++++++++++++++++++++++++ 2 files changed, 704 insertions(+) create mode 100644 src/game/affixes.ts create mode 100644 tests/affixes.test.ts diff --git a/src/game/affixes.ts b/src/game/affixes.ts new file mode 100644 index 0000000..9191ba1 --- /dev/null +++ b/src/game/affixes.ts @@ -0,0 +1,410 @@ +/** + * Magic affix tables loader and eligibility system (`MagicPrefix.txt` & `MagicSuffix.txt`). + * + * In Diablo II (1.13c): + * - MagicPrefix.txt: 670 raw rows - 32 empty Name - 1 Expansion = 637 real rows (587 spawnable=1). + * Columns: 41 (includes `etype1..etype5`). + * - MagicSuffix.txt: 748 raw rows - 6 empty Name - 1 Expansion = 741 real rows (575 spawnable=1). + * Columns: 39 (only has `etype1..etype3`). + * - Total: 1378 affixes (1162 spawnable=1). + * - Name displays directly from the txt table (does NOT use string.tbl). + */ + +import type { D2Table } from './acts.ts' +import { parseTable } from './acts.ts' +import type { MountedArchives } from '../mpq/mount.ts' + +/** Canonical archive path for magic prefixes. */ +export const MAGIC_PREFIX_TABLE_PATH = 'data\\global\\excel\\MagicPrefix.txt' + +/** Canonical archive path for magic suffixes. */ +export const MAGIC_SUFFIX_TABLE_PATH = 'data\\global\\excel\\MagicSuffix.txt' + +/** + * Modifier descriptor for a rolled affix. + */ +export interface AffixMod { + readonly code: string + readonly param?: string + readonly min: number + readonly max: number +} + +/** + * A single magic prefix or suffix record. + */ +export interface MagicAffix { + /** Affix name (direct display text, does NOT use string.tbl). */ + name: string + /** True for prefixes, false for suffixes. */ + isPrefix: boolean + /** Version flag (0 for Classic, 100 for Expansion). */ + version: number + /** Whether this affix can naturally spawn. */ + spawnable: boolean + /** Whether this affix can spawn on rare items. */ + rare: boolean + /** Minimum required item quality level (qlvl). */ + level: number + /** Maximum item level after which this affix will not spawn (0 if no limit). */ + maxlevel: number + /** Character level requirement to use the item with this affix. */ + levelreq: number + /** Class-specific restriction for prefixes (e.g. 'ama', 'sor', 'nec', etc.). */ + classspecific?: string + /** Class-specific restriction for suffixes (e.g. 'sor', 'ama', etc.). */ + class?: string + /** Class-specific level requirement. */ + classlevelreq?: number + /** Frequency / spawn weight. */ + frequency: number + /** Affix exclusivity group (only one affix from the same group can spawn). */ + group: number + /** Rolled modifiers (up to 3 slots). */ + mods: AffixMod[] + /** Whether this affix causes a color transformation on the item. */ + transform: boolean + /** Color code for transformation (e.g. 'dgld', 'cblu'). */ + transformColor?: string + /** Allowed item types (whitelist, up to 7 slots). */ + itypes: string[] + /** Excluded item types (blacklist, up to 5 for prefix, up to 3 for suffix). */ + etypes: string[] + /** Cost divisor. */ + divide?: number + /** Cost multiplier. */ + multiply?: number + /** Cost add amount. */ + add?: number +} + +/** Options for filtering eligible magic affixes. */ +export interface AffixEligibilityOptions { + /** Affix level (alvl) of the item. */ + alvl?: number + /** Whether the target item is rare. */ + isRare?: boolean +} + +/** + * Collection of magic affixes with grouped and indexed lookups. + */ +export interface AffixTable { + /** All valid, non-empty, non-Expansion affixes in file order. */ + readonly all: MagicAffix[] + /** Lookup map grouped by exact affix name. */ + readonly byName: Map + /** Lookup map grouped by affix group number. */ + readonly byGroup: Map + /** Whether this table contains prefixes (true) or suffixes (false). */ + readonly isPrefix: boolean + /** Total number of affixes. */ + readonly length: number + /** Get all affixes matching name (case-insensitive fallback). */ + getByName(name: string): MagicAffix[] + /** Get all affixes belonging to a group. */ + getByGroup(group: number): MagicAffix[] + /** Filter eligible affixes for an item type. */ + getEligible( + itemTypeCode: string, + isA: (type: string, target: string) => boolean, + options?: AffixEligibilityOptions, + ): MagicAffix[] + /** Iterable over all affixes. */ + [Symbol.iterator](): IterableIterator +} + +/** + * Tests whether a magic affix is eligible to spawn on an item of `itemTypeCode`. + * + * Checks: + * 1. `affix.spawnable` must be true. + * 2. If `options.isRare` is true, `affix.rare` must be true. + * 3. If `options.alvl` is provided, `affix.level <= options.alvl` and (`affix.maxlevel === 0 || options.alvl <= affix.maxlevel`). + * 4. Item must NOT match any `etype` (`isA(itemTypeCode, etype) === true`). + * 5. Item MUST match at least one `itype` (`isA(itemTypeCode, itype) === true`). + * + * @param affix - The magic affix to test. + * @param itemTypeCode - Target item type code (e.g. 'swor', 'armo'). + * @param isA - Item type hierarchy predicate: `(type, target) => boolean`. + * @param options - Optional filters (alvl and isRare). + * @returns `true` if the affix is eligible. + */ +export function isAffixEligible( + affix: MagicAffix, + itemTypeCode: string, + isA: (type: string, target: string) => boolean, + options?: AffixEligibilityOptions, +): boolean { + // 1. Must be spawnable + if (!affix.spawnable) { + return false + } + + // 2. Rare check: if options.isRare is true, affix.rare must be true + if (options?.isRare && !affix.rare) { + return false + } + + // 3. Affix level check + if (options?.alvl !== undefined) { + if (affix.level > options.alvl) { + return false + } + if (affix.maxlevel !== 0 && options.alvl > affix.maxlevel) { + return false + } + } + + // 4. Blacklist check: item must NOT match any etype + if (affix.etypes.length > 0 && affix.etypes.some(etype => isA(itemTypeCode, etype))) { + return false + } + + // 5. Whitelist check: item MUST match at least one itype + if (affix.itypes.length === 0 || !affix.itypes.some(itype => isA(itemTypeCode, itype))) { + return false + } + + return true +} + +/** + * Construct an AffixTable wrapper around an array of MagicAffix entries. + */ +function createAffixTable(all: MagicAffix[], isPrefix: boolean): AffixTable { + const byName = new Map() + const byGroup = new Map() + + for (const affix of all) { + // Index by name + let nameList = byName.get(affix.name) + if (!nameList) { + nameList = [] + byName.set(affix.name, nameList) + } + nameList.push(affix) + + // Index by group + let groupList = byGroup.get(affix.group) + if (!groupList) { + groupList = [] + byGroup.set(affix.group, groupList) + } + groupList.push(affix) + } + + return { + all, + byName, + byGroup, + isPrefix, + get length(): number { + return all.length + }, + getByName(name: string): MagicAffix[] { + const direct = byName.get(name) + if (direct) return direct + const lower = name.trim().toLowerCase() + for (const [k, v] of byName.entries()) { + if (k.toLowerCase() === lower) return v + } + return [] + }, + getByGroup(group: number): MagicAffix[] { + return byGroup.get(group) ?? [] + }, + getEligible( + itemTypeCode: string, + isA: (type: string, target: string) => boolean, + options?: AffixEligibilityOptions, + ): MagicAffix[] { + return all.filter(affix => isAffixEligible(affix, itemTypeCode, isA, options)) + }, + [Symbol.iterator](): IterableIterator { + return all[Symbol.iterator]() + }, + } +} + +/** + * Parse a D2Table (or Uint8Array / TSV string) into an AffixTable. + * + * Filters out: + * - Rows where Name is empty (trimmed) + * - Rows where Name is 'Expansion' (case-insensitive) + * + * @param table - Raw tab-separated D2Table, raw Uint8Array, or TSV string. + * @param isPrefix - True if parsing MagicPrefix.txt, false if parsing MagicSuffix.txt. + * @returns The parsed AffixTable. + */ +export function parseMagicAffixesTable(table: D2Table, isPrefix: boolean): AffixTable +export function parseMagicAffixesTable(bytes: Uint8Array, isPrefix: boolean): AffixTable +export function parseMagicAffixesTable(tsv: string, isPrefix: boolean): AffixTable +export function parseMagicAffixesTable( + input: D2Table | Uint8Array | string, + isPrefix: boolean, +): AffixTable { + let table: D2Table + if (typeof input === 'string') { + table = parseTable(new TextEncoder().encode(input.trim())) + } else if (input instanceof Uint8Array) { + table = parseTable(input) + } else { + table = input + } + + const headerMap = new Map() + table.header.forEach((h, idx) => { + headerMap.set(h.trim().toLowerCase(), idx) + }) + + const getCell = (row: readonly string[], col: string): string => { + const idx = headerMap.get(col.toLowerCase()) + if (idx === undefined) return '' + return (row[idx] ?? '').trim() + } + + const all: MagicAffix[] = [] + + for (const row of table.rows) { + const name = getCell(row, 'name') + // Filter out rows where Name is empty or 'Expansion' + if (!name || name.toLowerCase() === 'expansion') { + continue + } + + const rawVersion = getCell(row, 'version') + const version = rawVersion ? Number.parseInt(rawVersion, 10) : 0 + const spawnable = getCell(row, 'spawnable') === '1' + const rare = getCell(row, 'rare') === '1' + const rawLevel = getCell(row, 'level') + const level = rawLevel ? Number.parseInt(rawLevel, 10) : 0 + const rawMaxLevel = getCell(row, 'maxlevel') + const maxlevel = rawMaxLevel ? Number.parseInt(rawMaxLevel, 10) : 0 + const rawLevelReq = getCell(row, 'levelreq') + const levelreq = rawLevelReq ? Number.parseInt(rawLevelReq, 10) : 0 + + const classspecific = getCell(row, 'classspecific') || undefined + const cls = getCell(row, 'class') || undefined + const rawClr = getCell(row, 'classlevelreq') + const classlevelreq = rawClr ? Number.parseInt(rawClr, 10) : undefined + + const rawFreq = getCell(row, 'frequency') + const frequency = rawFreq ? Number.parseInt(rawFreq, 10) : 0 + const rawGroup = getCell(row, 'group') + const group = rawGroup ? Number.parseInt(rawGroup, 10) : 0 + + const mods: AffixMod[] = [] + for (let slot = 1; slot <= 3; slot++) { + const code = getCell(row, `mod${slot}code`) + if (code) { + const rawParam = getCell(row, `mod${slot}param`) + const param = rawParam !== '' ? rawParam : undefined + const rawMin = getCell(row, `mod${slot}min`) + const rawMax = getCell(row, `mod${slot}max`) + const min = rawMin !== '' ? Number.parseInt(rawMin, 10) : 0 + const max = rawMax !== '' ? Number.parseInt(rawMax, 10) : min + mods.push({ + code, + ...(param !== undefined ? { param } : {}), + min, + max, + }) + } + } + + const transform = getCell(row, 'transform') === '1' + const transformColor = getCell(row, 'transformcolor') || undefined + + const itypes: string[] = [] + for (let slot = 1; slot <= 7; slot++) { + const itype = getCell(row, `itype${slot}`) + if (itype) { + itypes.push(itype) + } + } + + const etypes: string[] = [] + for (let slot = 1; slot <= 5; slot++) { + const etype = getCell(row, `etype${slot}`) + if (etype) { + etypes.push(etype) + } + } + + const rawDivide = getCell(row, 'divide') + const rawMultiply = getCell(row, 'multiply') + const rawAdd = getCell(row, 'add') + const divide = rawDivide !== '' ? Number.parseInt(rawDivide, 10) : undefined + const multiply = rawMultiply !== '' ? Number.parseInt(rawMultiply, 10) : undefined + const add = rawAdd !== '' ? Number.parseInt(rawAdd, 10) : undefined + + all.push({ + name, + isPrefix, + version, + spawnable, + rare, + level, + maxlevel, + levelreq, + ...(classspecific !== undefined ? { classspecific } : {}), + ...(cls !== undefined ? { class: cls } : {}), + ...(classlevelreq !== undefined ? { classlevelreq } : {}), + frequency, + group, + mods, + transform, + ...(transformColor !== undefined ? { transformColor } : {}), + itypes, + etypes, + ...(divide !== undefined ? { divide } : {}), + ...(multiply !== undefined ? { multiply } : {}), + ...(add !== undefined ? { add } : {}), + }) + } + + return createAffixTable(all, isPrefix) +} + +/** + * Safely read a file from mounted MPQ archives with forward/backward slash normalization. + */ +async function readMpqFile(archives: MountedArchives, primaryPath: string): Promise { + const normalized = primaryPath.replace(/\\/g, '/') + const backslashed = primaryPath.replace(/\//g, '\\') + const candidates = [ + primaryPath, + backslashed, + normalized, + backslashed.toLowerCase(), + normalized.toLowerCase(), + ] + for (const p of candidates) { + if (archives.has(p)) { + return archives.read(p) + } + } + return archives.read(primaryPath) +} + +/** + * Loads both `MagicPrefix.txt` and `MagicSuffix.txt` from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @returns An object containing parsed `prefixes` and `suffixes` AffixTables. + */ +export async function loadMagicAffixes(archives: MountedArchives): Promise<{ + prefixes: AffixTable + suffixes: AffixTable +}> { + const [prefixBytes, suffixBytes] = await Promise.all([ + readMpqFile(archives, MAGIC_PREFIX_TABLE_PATH), + readMpqFile(archives, MAGIC_SUFFIX_TABLE_PATH), + ]) + return { + prefixes: parseMagicAffixesTable(parseTable(prefixBytes), true), + suffixes: parseMagicAffixesTable(parseTable(suffixBytes), false), + } +} diff --git a/tests/affixes.test.ts b/tests/affixes.test.ts new file mode 100644 index 0000000..d12359c --- /dev/null +++ b/tests/affixes.test.ts @@ -0,0 +1,294 @@ +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) + }) +}) + +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) + } + }) +})