feat(items): 加载 AutoMagic.txt (Issue #96)
This commit is contained in:
parent
5d6dc8a15f
commit
060291163c
|
|
@ -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<number, AutoMagicEntry>
|
||||
/** 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<AutoMagicEntry>
|
||||
/** 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<number, AutoMagicEntry>()
|
||||
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<string, number>()
|
||||
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<AutoMagicTable> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<string, string[]> = {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue