From 060291163cf6ff4f413b6b4bcdf7387b2937cfb6 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:13:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=8A=A0=E8=BD=BD=20AutoMagic.t?= =?UTF-8?q?xt=20(Issue=20#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/automagic.ts | 445 ++++++++++++++++++++++++++++++++++++++++ tests/automagic.test.ts | 271 ++++++++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 src/game/automagic.ts create mode 100644 tests/automagic.test.ts diff --git a/src/game/automagic.ts b/src/game/automagic.ts new file mode 100644 index 0000000..5da198e --- /dev/null +++ b/src/game/automagic.ts @@ -0,0 +1,445 @@ +/** + * AutoMagic: item auto-affixes loaded from AutoMagic.txt. + * + * In Diablo II, certain item classes (e.g. Amazon weapons, Sorceress orbs, + * Paladin shields, Necromancer heads) can spawn with "built-in" magical affixes + * even as white (normal) items, based on the `auto prefix` group defined in + * `weapons.txt` and `armor.txt`. + * + * In `.d2s` character saves, the `autoAffixId` is an 11-bit index referencing the + * exact row index (0..35) in `AutoMagic.txt`. Therefore, row order and indices + * must be strictly preserved without filtering or reordering. + */ +import { parseTable } from './acts.ts' +import type { D2Table } from './acts.ts' +import type { MountedArchives } from '../mpq/mount.ts' + +/** One modifier on an AutoMagic entry. */ +export interface AutoMagicMod { + readonly code: string + readonly param?: string + readonly min: number + readonly max: number +} + +/** One record from AutoMagic.txt, preserving its exact row index as id. */ +export interface AutoMagicEntry { + /** Row index 0..35, matching .d2s autoAffixId. */ + readonly id: number + /** Affix name (e.g. "Fletcher's", "of the Jackal"). */ + readonly name: string + /** Version (100 for LoD). */ + readonly version: number + /** Whether the automagic is spawnable (all 1 in 1.13c). */ + readonly spawnable: boolean + /** Whether the automagic can spawn on rare items. */ + readonly rare: boolean + /** Minimum item level required. */ + readonly level: number + /** Maximum item level after which it will not spawn (0 if no limit). */ + readonly maxlevel: number + /** Required character level to use. */ + readonly levelreq: number + /** Class-specific restriction (e.g. "ama", "sor"), if any. */ + readonly class?: string + /** Level requirement for that specific class, if any. */ + readonly classlevelreq?: number + /** Spawn frequency / weight. */ + readonly frequency: number + /** Auto prefix group (e.g. 300..305). */ + readonly group: number + /** Rolled modifiers (up to 3). */ + readonly mods: AutoMagicMod[] + /** Whether this automagic performs a color shift on the item. */ + readonly transform: boolean + /** Color code for color shift (e.g. "cblu", "dgld"). */ + readonly transformColor?: string + /** Allowed item types (itype1..7). */ + readonly itypes: string[] + /** Excluded item types (etype1..3). */ + readonly etypes: string[] + /** Price divide factor. */ + readonly divide?: number + /** Price multiply factor. */ + readonly multiply?: number + /** Price add factor. */ + readonly add?: number +} + +/** Predicate checking whether an item type is or inherits from target type. */ +export type IsAPredicate = (typeCode: string, targetType: string) => boolean + +/** Options for filtering eligible AutoMagic affixes. */ +export interface EligibleAutoMagicOptions { + /** Filter to a specific auto-prefix group (e.g. 300..305). */ + readonly group?: number + /** Whether the item is rare (if true, excludes entries where rare is false). */ + readonly rare?: boolean + /** Specific character class (e.g. "ama", "sor"). */ + readonly class?: string + /** AutoMagic table or entries to query from. */ + readonly table?: AutoMagicTable | readonly AutoMagicEntry[] +} + +/** A collection of AutoMagic entries with fast id lookup and eligibility helpers. */ +export interface AutoMagicTable { + /** Every AutoMagic entry in exact row order (indices 0..35). */ + readonly entries: readonly AutoMagicEntry[] + /** Fast lookup by row index / autoAffixId. */ + readonly byId: ReadonlyMap + /** Underlying D2Table, if loaded from one. */ + readonly table?: D2Table + /** Get entry by row id (0..35). */ + get(id: number): AutoMagicEntry | undefined + /** Filter eligible automagics for an item type and level. */ + getEligible( + typeCode: string, + alvl: number, + isA: IsAPredicate, + options?: EligibleAutoMagicOptions, + ): AutoMagicEntry[] + /** Allow iteration over all entries. */ + [Symbol.iterator](): IterableIterator + /** Number of entries. */ + readonly length: number + /** Alias for entries. */ + readonly rows: readonly AutoMagicEntry[] + /** Direct index access. */ + readonly [index: number]: AutoMagicEntry | undefined +} + +/** 1.13c gold standard AutoMagic.txt embedded TSV data. */ +export const CANONICAL_AUTOMAGIC_TSV = `Name\tversion\tspawnable\trare\tlevel\tmaxlevel\tlevelreq\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 +Fletcher's\t100\t1\t1\t1\t\t15\t\t\t3\t300\tskilltab\t0\t1\t1\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Bowyer's\t100\t1\t1\t40\t\t30\t\t\t2\t300\tskilltab\t0\t2\t2\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Archer's\t100\t1\t\t60\t\t45\t\t\t1\t300\tskilltab\t0\t3\t3\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Acrobat's\t100\t1\t1\t1\t\t15\t\t\t3\t301\tskilltab\t1\t1\t1\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Gymnast's\t100\t1\t1\t40\t\t30\t\t\t2\t301\tskilltab\t1\t2\t2\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Athlete's\t100\t1\t\t60\t\t45\t\t\t1\t301\tskilltab\t1\t3\t3\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Harpoonist's\t100\t1\t1\t1\t\t15\t\t\t3\t302\tskilltab\t2\t1\t1\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Spearmaiden's\t100\t1\t1\t40\t\t30\t\t\t2\t302\tskilltab\t2\t2\t2\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Lancer's\t100\t1\t\t60\t\t45\t\t\t1\t302\tskilltab\t2\t3\t3\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of the Jackal\t100\t1\t1\t1\t\t3\t\t\t6\t303\thp\t\t1\t5\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of the Fox\t100\t1\t1\t11\t\t8\t\t\t5\t303\thp\t\t6\t10\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of the Wolf\t100\t1\t1\t27\t\t20\t\t\t4\t303\thp\t\t11\t20\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of the Tiger\t100\t1\t1\t43\t\t35\t\t\t3\t303\thp\t\t21\t30\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of the Mammoth\t100\t1\t1\t59\t\t51\t\t\t2\t303\thp\t\t31\t40\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of the Colossus\t100\t1\t\t75\t\t67\t\t\t1\t303\thp\t\t41\t60\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Lizard's\t100\t1\t1\t3\t\t2\t\t\t4\t303\tmana\t\t1\t5\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Snake's\t100\t1\t1\t12\t\t9\t\t\t3\t303\tmana\t\t5\t10\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Serpent's\t100\t1\t1\t23\t\t16\t\t\t2\t303\tmana\t\t11\t20\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Drake's\t100\t1\t1\t34\t\t26\t\t\t2\t303\tmana\t\t21\t30\t\t\t\t\t\t\t\t\t1\tcblu\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Dragon's\t100\t1\t1\t45\t\t37\t\t\t1\t303\tmana\t\t31\t40\t\t\t\t\t\t\t\t\t1\tcblu\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Wyrm's\t100\t1\t1\t56\t\t48\t\t\t1\t303\tmana\t\t41\t60\t\t\t\t\t\t\t\t\t1\tcblu\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Great Wyrm's\t100\t1\t\t67\t\t59\t\t\t1\t303\tmana\t\t61\t80\t\t\t\t\t\t\t\t\t1\tcblu\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Shimmering\t100\t1\t1\t1\t\t3\t\t\t8\t304\tres-all\t\t5\t10\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Rainbow\t100\t1\t1\t18\t\t13\t\t\t7\t304\tres-all\t\t8\t15\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Scintillating\t100\t1\t1\t28\t\t21\t\t\t6\t304\tres-all\t\t16\t30\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Prismatic\t100\t1\t1\t39\t\t31\t\t\t5\t304\tres-all\t\t25\t35\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Chromatic\t100\t1\t\t50\t\t42\t\t\t4\t304\tres-all\t\t35\t45\t\t\t\t\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Sharp\t100\t1\t1\t10\t\t5\t\t\t3\t304\tatt\t\t15\t30\tdmg%\t\t10\t20\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Fine\t100\t1\t1\t18\t\t13\t\t\t3\t304\tatt\t\t31\t60\tdmg%\t\t21\t30\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Warrior's\t100\t1\t1\t30\t\t23\t\t\t2\t304\tatt\t\t61\t80\tdmg%\t\t31\t40\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Soldier's\t100\t1\t1\t42\t\t34\t\t\t2\t304\tatt\t\t81\t100\tdmg%\t\t41\t50\t\t\t\t\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +Knight's\t100\t1\t1\t50\t\t42\t\t\t2\t304\tatt\t\t101\t121\tdmg%\t\t51\t65\t\t\t\t\t1\tdgld\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of Blight\t100\t1\t1\t1\t\t3\t\t\t3\t305\tpois-min\t\t8\t8\tpois-max\t\t24\t24\tpois-len\t\t75\t75\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of Venom\t100\t1\t1\t15\t\t11\t\t\t2\t305\tpois-min\t\t16\t16\tpois-max\t\t48\t48\tpois-len\t\t75\t75\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of Pestilence\t100\t1\t1\t25\t\t18\t\t\t2\t305\tpois-min\t\t32\t32\tpois-max\t\t72\t72\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 +of Anthrax\t100\t1\t\t33\t\t25\t\t\t2\t305\tpois-min\t\t64\t64\tpois-max\t\t128\t128\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0` + +function createAutoMagicTable(entries: AutoMagicEntry[], table?: D2Table): AutoMagicTable { + const byId = new Map() + for (const entry of entries) { + byId.set(entry.id, entry) + } + const result: any = { + entries, + rows: entries, + byId, + table, + get: (id: number) => byId.get(id), + getEligible: (typeCode: string, alvl: number, isA: IsAPredicate, options?: EligibleAutoMagicOptions) => + filterEligibleAutoMagic(entries, typeCode, alvl, isA, options), + [Symbol.iterator]: () => entries[Symbol.iterator](), + } + Object.defineProperty(result, 'length', { + get: () => entries.length, + enumerable: true, + }) + for (let i = 0; i < entries.length; i++) { + result[i] = entries[i] + } + return result as AutoMagicTable +} + +/** Pre-parsed canonical 1.13c AutoMagic table. */ +export const CANONICAL_AUTOMAGIC_TABLE: AutoMagicTable = parseAutoMagicTable( + parseTable(new TextEncoder().encode(CANONICAL_AUTOMAGIC_TSV.trim())), +) + +/** + * Parse a D2Table (or Uint8Array/string) into an AutoMagicTable. + * + * Preserves the exact row order (indices 0..35) to maintain 1:1 parity with + * .d2s autoAffixId references. + * + * @param table - the tab-separated D2Table. + * @returns the parsed AutoMagicTable. + */ +export function parseAutoMagicTable(table: D2Table): AutoMagicTable +export function parseAutoMagicTable(bytes: Uint8Array): AutoMagicTable +export function parseAutoMagicTable(tsv: string): AutoMagicTable +export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoMagicTable { + 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 entries: AutoMagicEntry[] = [] + + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]! + const name = getCell(row, 'name') + const rawVersion = getCell(row, 'version') + const version = rawVersion ? Number.parseInt(rawVersion, 10) : 100 + const rawSpawnable = getCell(row, 'spawnable') + const spawnable = rawSpawnable === '1' + const rawRare = getCell(row, 'rare') + const rare = rawRare === '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 cls = getCell(row, 'class') || undefined + const rawClassLevelReq = getCell(row, 'classlevelreq') + const classlevelreq = rawClassLevelReq ? Number.parseInt(rawClassLevelReq, 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: AutoMagicMod[] = [] + 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 rawTransform = getCell(row, 'transform') + const transform = rawTransform === '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 <= 3; 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) : 0 + const multiply = rawMultiply !== '' ? Number.parseInt(rawMultiply, 10) : 0 + const add = rawAdd !== '' ? Number.parseInt(rawAdd, 10) : 0 + + entries.push({ + id: i, + name, + version, + spawnable, + rare, + level, + maxlevel, + levelreq, + ...(cls !== undefined ? { class: cls } : {}), + ...(classlevelreq !== undefined ? { classlevelreq } : {}), + frequency, + group, + mods, + transform, + ...(transformColor !== undefined ? { transformColor } : {}), + itypes, + etypes, + divide, + multiply, + add, + }) + } + + return createAutoMagicTable(entries, table) +} + +/** + * Load AutoMagic.txt from mounted MPQ archives. + * + * @param archives - mounted MPQ archives. + * @returns the parsed AutoMagicTable. + */ +export async function loadAutoMagic(archives: MountedArchives): Promise { + const candidatePaths = [ + 'data/global/excel/AutoMagic.txt', + 'data\\global\\excel\\AutoMagic.txt', + 'data/global/excel/automagic.txt', + 'data\\global\\excel\\automagic.txt', + ] + let bytes: Uint8Array | undefined + for (const p of candidatePaths) { + if (archives.has(p)) { + bytes = await archives.read(p) + break + } + } + if (!bytes) { + bytes = await archives.read('data/global/excel/AutoMagic.txt') + } + return parseAutoMagicTable(parseTable(bytes)) +} + +function filterEligibleAutoMagic( + entries: readonly AutoMagicEntry[], + typeCode: string, + alvl: number, + isA: IsAPredicate, + options?: EligibleAutoMagicOptions, +): AutoMagicEntry[] { + return entries.filter(entry => { + // 1. Must be spawnable + if (!entry.spawnable) return false + + // 2. Minimum affix level requirement + if (entry.level > alvl) return false + + // 3. Maximum affix level limit + if (entry.maxlevel > 0 && alvl > entry.maxlevel) return false + + // 4. Rare check: rare items exclude non-rare affixes + if (options?.rare && !entry.rare) return false + + // 5. Group filter (e.g. 300 for Amazon bows, 304 for Paladin shields) + if (options?.group !== undefined && entry.group !== options.group) return false + + // 6. Class check if entry requires specific class + if (entry.class && options?.class && entry.class !== options.class) return false + + // 7. Exclusion types (etype1..3) + if (entry.etypes.length > 0 && entry.etypes.some(etype => etype === typeCode || isA(typeCode, etype))) { + return false + } + + // 8. Inclusion types (itype1..7) + if (entry.itypes.length > 0 && !entry.itypes.some(itype => itype === typeCode || isA(typeCode, itype))) { + return false + } + + return true + }) +} + +/** + * Filter eligible AutoMagic entries for a given item type code and affix level. + * + * Supports flexible invocations: + * - `getEligibleAutoMagic(typeCode, alvl, isA, options?)` + * - `getEligibleAutoMagic(typeCode, alvl, isA, table, options?)` + * - `getEligibleAutoMagic(table, typeCode, alvl, isA, options?)` + */ +export function getEligibleAutoMagic( + typeCode: string, + alvl: number, + isA: IsAPredicate, + tableOrOptionsOrGroup?: AutoMagicTable | readonly AutoMagicEntry[] | EligibleAutoMagicOptions | number | boolean, + options?: EligibleAutoMagicOptions, +): AutoMagicEntry[] +export function getEligibleAutoMagic( + table: AutoMagicTable | readonly AutoMagicEntry[], + typeCode: string, + alvl: number, + isA: IsAPredicate, + options?: EligibleAutoMagicOptions, +): AutoMagicEntry[] +export function getEligibleAutoMagic( + first: string | AutoMagicTable | readonly AutoMagicEntry[], + second: number | string, + third: IsAPredicate | number, + fourth?: AutoMagicTable | readonly AutoMagicEntry[] | EligibleAutoMagicOptions | number | boolean | IsAPredicate, + fifth?: EligibleAutoMagicOptions, +): AutoMagicEntry[] { + let entries: readonly AutoMagicEntry[] = CANONICAL_AUTOMAGIC_TABLE.entries + let typeCode: string + let alvl: number + let isA: IsAPredicate + let options: EligibleAutoMagicOptions | undefined + + if (typeof first === 'string') { + typeCode = first + alvl = second as number + isA = third as IsAPredicate + + if (typeof fourth === 'number') { + options = { group: fourth, ...fifth } + } else if (typeof fourth === 'boolean') { + options = { rare: fourth, ...fifth } + } else if (Array.isArray(fourth)) { + entries = fourth + options = fifth + } else if (fourth && typeof fourth === 'object') { + if ('entries' in fourth) { + entries = (fourth as AutoMagicTable).entries + options = fifth + } else if ('table' in fourth && fourth.table) { + const t = fourth.table + entries = Array.isArray(t) ? t : (t as AutoMagicTable).entries + options = fourth as EligibleAutoMagicOptions + } else { + options = fourth as EligibleAutoMagicOptions + } + } else { + options = fifth + } + } else { + entries = Array.isArray(first) ? first : (first as AutoMagicTable).entries + typeCode = second as string + alvl = third as number + isA = fourth as IsAPredicate + options = fifth + } + + return filterEligibleAutoMagic(entries, typeCode, alvl, isA, options) +} diff --git a/tests/automagic.test.ts b/tests/automagic.test.ts new file mode 100644 index 0000000..7007d6c --- /dev/null +++ b/tests/automagic.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from 'vitest' +import * as fs from 'node:fs' +import { + parseAutoMagicTable, + loadAutoMagic, + getEligibleAutoMagic, + CANONICAL_AUTOMAGIC_TABLE, + CANONICAL_AUTOMAGIC_TSV, + type AutoMagicTable, + type AutoMagicEntry, + type IsAPredicate, +} from '../src/game/automagic.ts' +import { parseTable } from '../src/game/acts.ts' +import type { D2Table } 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' + +const MPQ_DIR = 'samples/d2' +const hasMpqs = fs.existsSync(`${MPQ_DIR}/d2exp.mpq`) + +describe('AutoMagic.txt Table Structure & Parsing', () => { + test('CANONICAL_AUTOMAGIC_TABLE loads exactly 36 rows, all spawnable=true', () => { + expect(CANONICAL_AUTOMAGIC_TABLE.length).toBe(36) + expect(CANONICAL_AUTOMAGIC_TABLE.entries.length).toBe(36) + expect(CANONICAL_AUTOMAGIC_TABLE.rows.length).toBe(36) + + for (const entry of CANONICAL_AUTOMAGIC_TABLE.entries) { + expect(entry.spawnable).toBe(true) + expect(entry.version).toBe(100) + } + }) + + test('Preservation of row indices 0..35 matches .d2s autoAffixId specification', () => { + for (let index = 0; index < 36; index++) { + const entry = CANONICAL_AUTOMAGIC_TABLE.entries[index]! + expect(entry.id).toBe(index) + expect(CANONICAL_AUTOMAGIC_TABLE.get(index)?.id).toBe(index) + expect(CANONICAL_AUTOMAGIC_TABLE.byId.get(index)?.id).toBe(index) + expect(CANONICAL_AUTOMAGIC_TABLE[index]?.id).toBe(index) + } + }) + + test('Table header has 38 columns and does not have classspecific column', () => { + const rawTable = parseTable(new TextEncoder().encode(CANONICAL_AUTOMAGIC_TSV.trim())) + expect(rawTable.header.length).toBe(38) + expect(rawTable.rows.length).toBe(36) + + // Contrast with MagicPrefix (41 columns and classspecific) + const lowerHeaders = rawTable.header.map(h => h.toLowerCase()) + expect(lowerHeaders.includes('classspecific')).toBe(false) + expect(lowerHeaders.includes('spawnable')).toBe(true) + expect(lowerHeaders.includes('transformcolor')).toBe(true) + }) + + test('First entry (id: 0) is Fletcher\'s with single skilltab mod', () => { + const first = CANONICAL_AUTOMAGIC_TABLE.get(0)! + expect(first).toBeDefined() + expect(first.id).toBe(0) + expect(first.name).toBe("Fletcher's") + expect(first.version).toBe(100) + expect(first.spawnable).toBe(true) + expect(first.rare).toBe(true) + expect(first.level).toBe(1) + expect(first.maxlevel).toBe(0) + expect(first.levelreq).toBe(15) + expect(first.frequency).toBe(3) + expect(first.group).toBe(300) + expect(first.transform).toBe(false) + expect(first.transformColor).toBeUndefined() + expect(first.itypes).toEqual(['armo', 'weap']) + expect(first.etypes).toEqual([]) + + expect(first.mods).toEqual([ + { code: 'skilltab', param: '0', min: 1, max: 1 }, + ]) + }) + + test('Last entry (id: 35) is of Anthrax with 3 poison mods', () => { + const last = CANONICAL_AUTOMAGIC_TABLE.get(35)! + expect(last).toBeDefined() + expect(last.id).toBe(35) + expect(last.name).toBe('of Anthrax') + expect(last.version).toBe(100) + expect(last.spawnable).toBe(true) + expect(last.rare).toBe(false) // cannot spawn on rare + expect(last.level).toBe(33) + expect(last.maxlevel).toBe(0) + expect(last.levelreq).toBe(25) + expect(last.frequency).toBe(2) + expect(last.group).toBe(305) + expect(last.transform).toBe(false) + expect(last.itypes).toEqual(['armo', 'weap']) + expect(last.etypes).toEqual([]) + + expect(last.mods).toEqual([ + { code: 'pois-min', min: 64, max: 64 }, + { code: 'pois-max', min: 128, max: 128 }, + { code: 'pois-len', min: 100, max: 100 }, + ]) + }) + + test('Transform flags and colors parsed accurately (Drake\'s..Great Wyrm\'s and Knight\'s)', () => { + // Rows 18..21 (Drake's, Dragon's, Wyrm's, Great Wyrm's) have cblu + for (let id = 18; id <= 21; id++) { + const entry = CANONICAL_AUTOMAGIC_TABLE.get(id)! + expect(entry.transform).toBe(true) + expect(entry.transformColor).toBe('cblu') + } + + // Row 31 (Knight's) has dgld + const knights = CANONICAL_AUTOMAGIC_TABLE.get(31)! + expect(knights.transform).toBe(true) + expect(knights.transformColor).toBe('dgld') + + // Non-transform rows + const jackal = CANONICAL_AUTOMAGIC_TABLE.get(9)! + expect(jackal.transform).toBe(false) + expect(jackal.transformColor).toBeUndefined() + }) + + test('2-mod entries (e.g. Sharp id 27) parse both attack rating and damage percent', () => { + const sharp = CANONICAL_AUTOMAGIC_TABLE.get(27)! + expect(sharp.name).toBe('Sharp') + expect(sharp.group).toBe(304) + expect(sharp.mods).toHaveLength(2) + expect(sharp.mods[0]).toEqual({ code: 'att', min: 15, max: 30 }) + expect(sharp.mods[1]).toEqual({ code: 'dmg%', min: 10, max: 20 }) + }) + + test('AutoMagicTable implements Iterable and Array-like index access', () => { + const list = [...CANONICAL_AUTOMAGIC_TABLE] + expect(list.length).toBe(36) + expect(list[0]?.name).toBe("Fletcher's") + expect(list[35]?.name).toBe('of Anthrax') + expect(CANONICAL_AUTOMAGIC_TABLE[0]?.name).toBe("Fletcher's") + expect(CANONICAL_AUTOMAGIC_TABLE[35]?.name).toBe('of Anthrax') + }) +}) + +describe('loadAutoMagic from MPQ archives', () => { + test.runIf(hasMpqs)('loads AutoMagic.txt from d2exp.mpq and matches canonical table', async () => { + 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 table = await loadAutoMagic(archives) + expect(table.length).toBe(36) + + for (let i = 0; i < 36; i++) { + const fromMpq = table.get(i)! + const fromCanonical = CANONICAL_AUTOMAGIC_TABLE.get(i)! + expect(fromMpq.id).toBe(fromCanonical.id) + expect(fromMpq.name).toBe(fromCanonical.name) + expect(fromMpq.level).toBe(fromCanonical.level) + expect(fromMpq.levelreq).toBe(fromCanonical.levelreq) + expect(fromMpq.group).toBe(fromCanonical.group) + expect(fromMpq.spawnable).toBe(fromCanonical.spawnable) + expect(fromMpq.rare).toBe(fromCanonical.rare) + expect(fromMpq.transform).toBe(fromCanonical.transform) + expect(fromMpq.transformColor).toBe(fromCanonical.transformColor) + expect(fromMpq.mods).toEqual(fromCanonical.mods) + expect(fromMpq.itypes).toEqual(fromCanonical.itypes) + expect(fromMpq.etypes).toEqual(fromCanonical.etypes) + } + }) +}) + +describe('getEligibleAutoMagic Filtering', () => { + const typeHierarchy: Record = { + abow: ['bow', 'weap'], + ashd: ['shld', 'armo'], + head: ['shld', 'armo'], + orb: ['wand', 'weap'], + swor: ['weap'], + ring: ['misc'], + boot: ['armo'], + } + + const isA: IsAPredicate = (child, parent) => { + if (child === parent) return true + return typeHierarchy[child]?.includes(parent) ?? false + } + + test('filters out items with incompatible item types (e.g. ring)', () => { + const eligibleForRing = getEligibleAutoMagic('ring', 99, isA) + expect(eligibleForRing).toHaveLength(0) + }) + + test('filters by level (alvl) correctly', () => { + // Amazon Bow skill group (300): + // Fletcher's: lvl 1 + // Bowyer's: lvl 40 + // Archer's: lvl 60 + const atLvl1 = getEligibleAutoMagic('abow', 1, isA, { group: 300 }) + expect(atLvl1.map(e => e.name)).toEqual(["Fletcher's"]) + + const atLvl39 = getEligibleAutoMagic('abow', 39, isA, { group: 300 }) + expect(atLvl39.map(e => e.name)).toEqual(["Fletcher's"]) + + const atLvl40 = getEligibleAutoMagic('abow', 40, isA, { group: 300 }) + expect(atLvl40.map(e => e.name)).toEqual(["Fletcher's", "Bowyer's"]) + + const atLvl60 = getEligibleAutoMagic('abow', 60, isA, { group: 300 }) + expect(atLvl60.map(e => e.name)).toEqual(["Fletcher's", "Bowyer's", "Archer's"]) + }) + + test('filters by rare compatibility', () => { + // Group 300 at alvl 60: + // Archer's has rare = false + const forMagic = getEligibleAutoMagic('abow', 60, isA, { group: 300, rare: false }) + expect(forMagic.map(e => e.name)).toEqual(["Fletcher's", "Bowyer's", "Archer's"]) + + const forRare = getEligibleAutoMagic('abow', 60, isA, { group: 300, rare: true }) + expect(forRare.map(e => e.name)).toEqual(["Fletcher's", "Bowyer's"]) + + // Group 305 (Poison damage on Necromancer heads): + // of Blight (lvl 1, rare: 1) + // of Venom (lvl 15, rare: 1) + // of Pestilence (lvl 25, rare: 1) + // of Anthrax (lvl 33, rare: 0) + const headsForRare = getEligibleAutoMagic('head', 50, isA, { group: 305, rare: true }) + expect(headsForRare.map(e => e.name)).toEqual(['of Blight', 'of Venom', 'of Pestilence']) + expect(headsForRare.some(e => e.name === 'of Anthrax')).toBe(false) + }) + + test('filters by group (Paladin shields group 304)', () => { + const palShields = getEligibleAutoMagic('ashd', 50, isA, { group: 304 }) + // All returned entries must be group 304 + expect(palShields.length).toBeGreaterThan(0) + expect(palShields.every(e => e.group === 304)).toBe(true) + expect(palShields.some(e => e.name === 'Shimmering')).toBe(true) + expect(palShields.some(e => e.name === 'Chromatic')).toBe(true) + }) + + test('supports calling with table as first argument or method on table', () => { + const res1 = getEligibleAutoMagic(CANONICAL_AUTOMAGIC_TABLE, 'ashd', 20, isA, { group: 304 }) + const res2 = CANONICAL_AUTOMAGIC_TABLE.getEligible('ashd', 20, isA, { group: 304 }) + const res3 = getEligibleAutoMagic('ashd', 20, isA, CANONICAL_AUTOMAGIC_TABLE, { group: 304 }) + + expect(res1.map(e => e.id)).toEqual(res2.map(e => e.id)) + expect(res1.map(e => e.id)).toEqual(res3.map(e => e.id)) + }) + + test('supports etype exclusions and maxlevel limits in mock table', () => { + const mockTsv = `Name\tversion\tspawnable\trare\tlevel\tmaxlevel\tlevelreq\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 +TestA\t100\t1\t1\t1\t20\t1\t\t\t1\t999\thp\t\t5\t5\t\t\t\t\t\t\t\t\t\t\tweap\t\t\t\t\t\t\tswor\t\t\t0\t0\t0 +TestB\t100\t1\t1\t10\t\t1\t\t\t1\t999\tmana\t\t5\t5\t\t\t\t\t\t\t\t\t\t\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0 +NotSpawnable\t100\t0\t1\t1\t\t1\t\t\t1\t999\thp\t\t1\t1\t\t\t\t\t\t\t\t\t\t\tweap\t\t\t\t\t\t\t\t\t\t0\t0\t0` + + const mockTable = parseAutoMagicTable(mockTsv) + expect(mockTable.length).toBe(3) + expect(mockTable.get(2)?.spawnable).toBe(false) + + // TestA excludes 'swor' via etype1 + const sworEligible = getEligibleAutoMagic(mockTable, 'swor', 15, isA) + expect(sworEligible.map(e => e.name)).toEqual(['TestB']) + + // TestA is allowed on 'abow', but has maxlevel = 20 + const bowAt15 = getEligibleAutoMagic(mockTable, 'abow', 15, isA) + expect(bowAt15.map(e => e.name)).toEqual(['TestA', 'TestB']) + + const bowAt25 = getEligibleAutoMagic(mockTable, 'abow', 25, isA) + // TestA has maxlevel = 20, so at alvl=25 it is excluded + expect(bowAt25.map(e => e.name)).toEqual(['TestB']) + }) +})