feat(items): 加载 MagicPrefix.txt 与 MagicSuffix.txt (Issue #94)
This commit is contained in:
parent
7187154ac8
commit
866cdd4988
|
|
@ -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