459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
/**
|
|
* 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
|
|
/** Class-specific 3-letter class code for the target item (e.g. 'ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'; '' for generic). */
|
|
readonly itemClass?: string | undefined
|
|
}
|
|
|
|
/**
|
|
* Canonical 1.13c mapping from 3-letter class code to ItemTypes.txt class-specific type codes.
|
|
*/
|
|
export const CLASS_SPECIFIC_ITEM_TYPE_MAP: Readonly<Record<string, readonly string[]>> = {
|
|
ama: ['amaz', 'abow', 'aspe', 'ajav'],
|
|
sor: ['sorc', 'orb'],
|
|
nec: ['necr', 'head'],
|
|
pal: ['pala', 'ashd'],
|
|
bar: ['barb', 'phlm'],
|
|
dru: ['drui', 'pelt'],
|
|
ass: ['assn', 'h2h', 'h2h2'],
|
|
}
|
|
|
|
/**
|
|
* Infers the 3-letter class code ('ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' | '')
|
|
* for an `itemTypeCode` using `isA` hierarchy checks.
|
|
*/
|
|
export function inferItemClassFromType(
|
|
itemTypeCode: string,
|
|
isA?: (type: string, target: string) => boolean,
|
|
): string {
|
|
const code = itemTypeCode.trim().toLowerCase()
|
|
if (!code) return ''
|
|
for (const [cls, targets] of Object.entries(CLASS_SPECIFIC_ITEM_TYPE_MAP)) {
|
|
for (const target of targets) {
|
|
if (code === target || (isA && isA(itemTypeCode, target))) {
|
|
return cls
|
|
}
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* 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. If `affix.classspecific` is non-empty, the item's effective class (`options.itemClass` or inferred via `isA`) must match `affix.classspecific`.
|
|
* 5. Item must NOT match any `etype` (`isA(itemTypeCode, etype) === true`).
|
|
* 6. 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, isRare, itemClass).
|
|
* @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. Class-specific restriction check (1.13c MagicPrefix.txt / MagicSuffix.txt `classspecific` column)
|
|
const requiredClass = affix.classspecific?.trim().toLowerCase()
|
|
if (requiredClass) {
|
|
const effectiveItemClass =
|
|
options?.itemClass !== undefined
|
|
? options.itemClass.trim().toLowerCase()
|
|
: inferItemClassFromType(itemTypeCode, isA)
|
|
if (effectiveItemClass !== requiredClass) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 5. Blacklist check: item must NOT match any etype
|
|
if (affix.etypes.length > 0 && affix.etypes.some(etype => isA(itemTypeCode, etype))) {
|
|
return false
|
|
}
|
|
|
|
// 6. 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),
|
|
}
|
|
}
|