feat(items): 加载 MagicPrefix.txt 与 MagicSuffix.txt (Issue #94)
This commit is contained in:
commit
625e31a5d1
|
|
@ -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<string, MagicAffix[]>
|
||||
/** Lookup map grouped by affix group number. */
|
||||
readonly byGroup: Map<number, MagicAffix[]>
|
||||
/** 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<MagicAffix>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, MagicAffix[]>()
|
||||
const byGroup = new Map<number, MagicAffix[]>()
|
||||
|
||||
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<MagicAffix> {
|
||||
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<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 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<Uint8Array> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue