450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
/**
|
||
* Diablo II gem and rune socketing loader and modifier dispatch (`Gems.txt`).
|
||
*
|
||
* Gold standard reference: 1.13c MPQ `data/global/excel/Gems.txt` and `ItemTypes.txt`.
|
||
*
|
||
* In Diablo II, socketable items (weapons, helms, body armor, and shields)
|
||
* can have gems (35 types: 7 gemstones × 5 quality tiers) and runes (33 runes:
|
||
* El (r01) to Zod (r33)) socketed into them.
|
||
*
|
||
* Key data and game mechanics:
|
||
* 1. `Gems.txt`:
|
||
* - 69 raw lines in 1.13c; minus the 'Expansion' section separator row = exactly 68 data rows × 41 columns.
|
||
* - 35 gems (indices 0..34) + 33 runes (indices 36..68, row 35 is 'Expansion').
|
||
* - Columns: `name`, `letter`, `transform`, `code`, `nummods`,
|
||
* `weaponMod1..3`, `helmMod1..3`, `shieldMod1..3` (each with Code, Param, Min, Max).
|
||
* 2. Socket modifier dispatch:
|
||
* - Weapon (`weap`, `mele`, `bow`, etc.) -> `weaponMod1..3`
|
||
* - Helm OR Body Armor (`tors`) -> `helmMod1..3`
|
||
* (CRITICAL: `helm` mods apply to both helmets AND body armor/torso!)
|
||
* - Shield (`shld`, `shie`, `ashd`, `head`) -> `shieldMod1..3`
|
||
* 3. Socket limit calculation (`ItemTypes.txt`):
|
||
* - `ilvl 1..24`: `maxSock1`
|
||
* - `ilvl 25..39`: `maxSock25`
|
||
* - `ilvl 40+`: `maxSock40`
|
||
* - Allowed sockets = `Math.min(base.gemsockets, itemType.maxSockN)`.
|
||
*/
|
||
|
||
import type { D2Table } from './acts.ts'
|
||
import { cell, parseTable } from './acts.ts'
|
||
import type { MountedArchives } from '../mpq/mount.ts'
|
||
import type { ItemTypeDefinition } from './item-types.ts'
|
||
|
||
/** File path of `Gems.txt` inside MPQ archives. */
|
||
export const GEMS_TABLE_PATH = 'data\\global\\excel\\Gems.txt'
|
||
|
||
/**
|
||
* Modifier descriptor for a gem or rune socketed into an item.
|
||
*/
|
||
export interface GemModifier {
|
||
/** Stat property code (e.g. 'att', 'str', 'ac', 'dmg-fire', 'thorns'). */
|
||
readonly code: string
|
||
/** Optional parameter (e.g. duration in frames like 125 or 75, or 0). */
|
||
readonly param?: number | string | undefined
|
||
/** Alias for param for compatibility with UniqueItemProp / SetBonusProp. */
|
||
readonly par?: number | string | undefined
|
||
/** Minimum roll value. */
|
||
readonly min: number
|
||
/** Maximum roll value. */
|
||
readonly max: number
|
||
}
|
||
|
||
/**
|
||
* Definition of an individual gem or rune entry from `Gems.txt`.
|
||
*/
|
||
export interface Gem {
|
||
/** Name of the gem or rune (e.g. 'Chipped Amethyst', 'El Rune'). */
|
||
readonly name: string
|
||
/** 3-letter item code (e.g. 'gcv', 'r01'). */
|
||
readonly code: string
|
||
/** Rune display letter (e.g. 'El', 'Eld') if present. */
|
||
readonly letter?: string | undefined
|
||
/** Color transform ID if present. */
|
||
readonly transform?: number | undefined
|
||
/** Number of modifier categories or mods specified in table. */
|
||
readonly nummods: number
|
||
/** Whether this record represents a rune (r01..r33). */
|
||
readonly isRune: boolean
|
||
/** Whether this record represents a gemstone (35 standard gems). */
|
||
readonly isGem: boolean
|
||
/** Modifiers applied when socketed into a weapon. */
|
||
readonly weaponMods: readonly GemModifier[]
|
||
/** Modifiers applied when socketed into a helm or torso (body armor). */
|
||
readonly helmMods: readonly GemModifier[]
|
||
/** Modifiers applied when socketed into a shield. */
|
||
readonly shieldMods: readonly GemModifier[]
|
||
|
||
/** Individual modifier accessors */
|
||
readonly weaponMod1?: GemModifier | undefined
|
||
readonly weaponMod2?: GemModifier | undefined
|
||
readonly weaponMod3?: GemModifier | undefined
|
||
readonly helmMod1?: GemModifier | undefined
|
||
readonly helmMod2?: GemModifier | undefined
|
||
readonly helmMod3?: GemModifier | undefined
|
||
readonly shieldMod1?: GemModifier | undefined
|
||
readonly shieldMod2?: GemModifier | undefined
|
||
readonly shieldMod3?: GemModifier | undefined
|
||
}
|
||
|
||
/**
|
||
* Case-insensitive, whitespace-tolerant Map for looking up gems by code or name.
|
||
*/
|
||
class CaseInsensitiveMap<V> extends Map<string, V> {
|
||
private normalize(key: string): string {
|
||
return typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase()
|
||
}
|
||
|
||
override get(key: string): V | undefined {
|
||
return super.get(this.normalize(key))
|
||
}
|
||
|
||
override has(key: string): boolean {
|
||
return super.has(this.normalize(key))
|
||
}
|
||
|
||
override set(key: string, value: V): this {
|
||
return super.set(this.normalize(key), value)
|
||
}
|
||
|
||
override delete(key: string): boolean {
|
||
return super.delete(this.normalize(key))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parsed Gems.txt table providing collection and lookup access.
|
||
*/
|
||
export interface GemTable {
|
||
/** All parsed gem and rune records in file order (68 records in vanilla 1.13c). */
|
||
readonly all: readonly Gem[]
|
||
/** Alias for `all`. */
|
||
readonly rows: readonly Gem[]
|
||
/** 35 standard gemstone records. */
|
||
readonly gems: readonly Gem[]
|
||
/** 33 rune records. */
|
||
readonly runes: readonly Gem[]
|
||
/** Map of gems/runes indexed by 3-letter code (case-insensitive). */
|
||
readonly byCode: ReadonlyMap<string, Gem>
|
||
/** Map of gems/runes indexed by name (case-insensitive). */
|
||
readonly byName: ReadonlyMap<string, Gem>
|
||
/** Lookup a gem or rune by code or name (case-insensitive). */
|
||
get(codeOrName: string): Gem | undefined
|
||
/** Total count of records (68 in vanilla 1.13c). */
|
||
readonly length: number
|
||
/** Allows iteration over all gem and rune records. */
|
||
[Symbol.iterator](): IterableIterator<Gem>
|
||
/** Array index access. */
|
||
readonly [index: number]: Gem | undefined
|
||
}
|
||
|
||
/**
|
||
* Parses a parameter cell into number or string or undefined.
|
||
*/
|
||
export function parseParam(val: string | undefined): number | string | undefined {
|
||
if (val === undefined) return undefined
|
||
const trimmed = val.trim()
|
||
if (trimmed === '') return undefined
|
||
if (/^-?\d+$/.test(trimmed)) {
|
||
const num = Number(trimmed)
|
||
if (Number.isFinite(num)) return num
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
/**
|
||
* Parses a numeric cell with fallback.
|
||
*/
|
||
export function parseNumber(val: string | undefined, fallback = 0): number {
|
||
if (val === undefined) return fallback
|
||
const trimmed = val.trim()
|
||
if (trimmed === '') return fallback
|
||
const num = Number(trimmed)
|
||
return Number.isFinite(num) ? num : fallback
|
||
}
|
||
|
||
/**
|
||
* Predicate function to check if `typeCode` descends from `ancestorCode`.
|
||
*/
|
||
export type IsAPredicate = (typeCode: string, ancestorCode: string) => boolean
|
||
|
||
/**
|
||
* Parses `Gems.txt` from a raw tab-separated D2Table, Uint8Array, or string.
|
||
*
|
||
* In Diablo II 1.13c:
|
||
* - 69 raw rows minus 1 'Expansion' row = exactly 68 data rows.
|
||
* - 35 gems + 33 runes.
|
||
* - Columns: name, letter, transform, code, nummods, weaponMod1..3, helmMod1..3, shieldMod1..3.
|
||
*
|
||
* @param input - Raw D2Table, Uint8Array, or TSV string.
|
||
* @returns Parsed `GemTable`.
|
||
*/
|
||
export function parseGemsTable(table: D2Table): GemTable
|
||
export function parseGemsTable(bytes: Uint8Array): GemTable
|
||
export function parseGemsTable(tsv: string): GemTable
|
||
export function parseGemsTable(input: D2Table | Uint8Array | string): GemTable
|
||
export function parseGemsTable(input: D2Table | Uint8Array | string): GemTable {
|
||
let table: D2Table
|
||
if (typeof input === 'string') {
|
||
table = parseTable(new TextEncoder().encode(input))
|
||
} else if (input instanceof Uint8Array) {
|
||
table = parseTable(input)
|
||
} else {
|
||
table = input
|
||
}
|
||
|
||
const all: Gem[] = []
|
||
const gems: Gem[] = []
|
||
const runes: Gem[] = []
|
||
const byCode = new CaseInsensitiveMap<Gem>()
|
||
const byName = new CaseInsensitiveMap<Gem>()
|
||
|
||
for (const row of table.rows) {
|
||
const code = cell(table, row, 'code').trim()
|
||
const name = cell(table, row, 'name').trim()
|
||
|
||
// Filter out rows where code is empty, or placeholder / expansion separator rows
|
||
if (!code || name === 'Expansion' || name === 'None') {
|
||
continue
|
||
}
|
||
|
||
const parseMod = (prefix: 'weapon' | 'helm' | 'shield', slot: number): GemModifier | undefined => {
|
||
const modCode = cell(table, row, `${prefix}Mod${slot}Code`).trim()
|
||
if (!modCode) return undefined
|
||
const rawParam = cell(table, row, `${prefix}Mod${slot}Param`)
|
||
const param = parseParam(rawParam)
|
||
const min = parseNumber(cell(table, row, `${prefix}Mod${slot}Min`), 0)
|
||
const max = parseNumber(cell(table, row, `${prefix}Mod${slot}Max`), 0)
|
||
return {
|
||
code: modCode,
|
||
...(param !== undefined ? { param, par: param } : {}),
|
||
min,
|
||
max,
|
||
}
|
||
}
|
||
|
||
const weaponMod1 = parseMod('weapon', 1)
|
||
const weaponMod2 = parseMod('weapon', 2)
|
||
const weaponMod3 = parseMod('weapon', 3)
|
||
const helmMod1 = parseMod('helm', 1)
|
||
const helmMod2 = parseMod('helm', 2)
|
||
const helmMod3 = parseMod('helm', 3)
|
||
const shieldMod1 = parseMod('shield', 1)
|
||
const shieldMod2 = parseMod('shield', 2)
|
||
const shieldMod3 = parseMod('shield', 3)
|
||
|
||
const weaponMods = [weaponMod1, weaponMod2, weaponMod3].filter((m): m is GemModifier => m !== undefined)
|
||
const helmMods = [helmMod1, helmMod2, helmMod3].filter((m): m is GemModifier => m !== undefined)
|
||
const shieldMods = [shieldMod1, shieldMod2, shieldMod3].filter((m): m is GemModifier => m !== undefined)
|
||
|
||
const letterRaw = cell(table, row, 'letter').trim()
|
||
const letter = letterRaw !== '' ? letterRaw : undefined
|
||
|
||
const transformRaw = cell(table, row, 'transform').trim()
|
||
const transform = transformRaw !== '' ? parseNumber(transformRaw) : undefined
|
||
|
||
const nummods = parseNumber(cell(table, row, 'nummods'), 0)
|
||
|
||
const isRune = /^r\d{2}$/i.test(code) || (letter !== undefined && letter !== '')
|
||
const isGem = !isRune
|
||
|
||
const gem: Gem = {
|
||
name,
|
||
code,
|
||
letter,
|
||
transform,
|
||
nummods,
|
||
isRune,
|
||
isGem,
|
||
weaponMods,
|
||
helmMods,
|
||
shieldMods,
|
||
...(weaponMod1 ? { weaponMod1 } : {}),
|
||
...(weaponMod2 ? { weaponMod2 } : {}),
|
||
...(weaponMod3 ? { weaponMod3 } : {}),
|
||
...(helmMod1 ? { helmMod1 } : {}),
|
||
...(helmMod2 ? { helmMod2 } : {}),
|
||
...(helmMod3 ? { helmMod3 } : {}),
|
||
...(shieldMod1 ? { shieldMod1 } : {}),
|
||
...(shieldMod2 ? { shieldMod2 } : {}),
|
||
...(shieldMod3 ? { shieldMod3 } : {}),
|
||
}
|
||
|
||
all.push(gem)
|
||
if (isRune) {
|
||
runes.push(gem)
|
||
} else {
|
||
gems.push(gem)
|
||
}
|
||
|
||
byCode.set(code, gem)
|
||
byName.set(name, gem)
|
||
}
|
||
|
||
const tableObj: GemTable = {
|
||
all,
|
||
rows: all,
|
||
gems,
|
||
runes,
|
||
byCode,
|
||
byName,
|
||
get(codeOrName: string): Gem | undefined {
|
||
return byCode.get(codeOrName) ?? byName.get(codeOrName)
|
||
},
|
||
get length(): number {
|
||
return all.length
|
||
},
|
||
[Symbol.iterator](): IterableIterator<Gem> {
|
||
return all[Symbol.iterator]()
|
||
},
|
||
}
|
||
|
||
// Support array index access table[0]
|
||
return new Proxy(tableObj, {
|
||
get(target, prop, receiver) {
|
||
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
|
||
return all[Number(prop)]
|
||
}
|
||
return Reflect.get(target, prop, receiver)
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Loads and parses `Gems.txt` from mounted MPQ archives.
|
||
*
|
||
* @param archives - Mounted MPQ archives.
|
||
* @param tablePath - Optional path inside archive (defaults to GEMS_TABLE_PATH).
|
||
* @returns Promise resolving to `GemTable`.
|
||
*/
|
||
export async function loadGems(
|
||
archives: MountedArchives,
|
||
tablePath = GEMS_TABLE_PATH,
|
||
): Promise<GemTable> {
|
||
const bytes = await archives.read(tablePath)
|
||
const d2Table = parseTable(bytes)
|
||
return parseGemsTable(d2Table)
|
||
}
|
||
|
||
/**
|
||
* Dispatches gem modifiers based on target item type.
|
||
*
|
||
* Rules:
|
||
* - Weapon (`weap`, `mele`, `bow`, `swor`, etc.) -> `weaponMod1..3`
|
||
* - Helm OR Body Armor (`tors`) -> `helmMod1..3`
|
||
* (CRITICAL: In Diablo II, `helm` mods apply to both helmets AND body armor!)
|
||
* - Shield (`shld`, `shie`, `ashd`, `head`) -> `shieldMod1..3`
|
||
* - Other types -> `[]`
|
||
*
|
||
* @param gemCode - Gem or rune 3-letter code (e.g. 'gcv', 'r01').
|
||
* @param itemTypeCode - Item type code of socketed item (e.g. 'swor', 'helm', 'tors', 'shie').
|
||
* @param isA - Hierarchy predicate `(typeCode, ancestorCode) => boolean` or Function.
|
||
* @param gemTable - Parsed gem table.
|
||
* @returns Array of modifiers applied to the socketed item.
|
||
*/
|
||
export function getGemModifiers(
|
||
gemCode: string,
|
||
itemTypeCode: string,
|
||
isA: IsAPredicate | Function,
|
||
gemTable: GemTable,
|
||
): GemModifier[] {
|
||
const gem = gemTable.get(gemCode)
|
||
if (!gem) return []
|
||
|
||
const code = itemTypeCode.trim().toLowerCase()
|
||
|
||
const safeIsA = (child: string, parent: string): boolean => {
|
||
if (typeof isA !== 'function') return false
|
||
try {
|
||
return Boolean(isA(child, parent))
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 1. Weapon check
|
||
const isWeapon =
|
||
code === 'weap' ||
|
||
code === 'weapon' ||
|
||
code === 'swor' ||
|
||
code === 'sword' ||
|
||
safeIsA(code, 'weap') ||
|
||
safeIsA(code, 'mele') ||
|
||
safeIsA(code, 'bow') ||
|
||
safeIsA(code, 'miss') ||
|
||
safeIsA(code, 'rod')
|
||
|
||
if (isWeapon) {
|
||
return [...gem.weaponMods]
|
||
}
|
||
|
||
// 2. Helm OR Body Armor (torso) check
|
||
// CRITICAL: Helm mods apply to both helmets AND body armor ('tors')!
|
||
const isHelmOrTorso =
|
||
code === 'helm' ||
|
||
code === 'helmet' ||
|
||
code === 'tors' ||
|
||
code === 'torso' ||
|
||
code === 'body' ||
|
||
safeIsA(code, 'helm') ||
|
||
safeIsA(code, 'tors') ||
|
||
safeIsA(code, 'torso') ||
|
||
safeIsA(code, 'phlm') ||
|
||
safeIsA(code, 'pelt') ||
|
||
safeIsA(code, 'circ')
|
||
|
||
if (isHelmOrTorso) {
|
||
return [...gem.helmMods]
|
||
}
|
||
|
||
// 3. Shield check
|
||
const isShield =
|
||
code === 'shld' ||
|
||
code === 'shie' ||
|
||
code === 'shield' ||
|
||
safeIsA(code, 'shld') ||
|
||
safeIsA(code, 'shie') ||
|
||
safeIsA(code, 'shield') ||
|
||
safeIsA(code, 'ashd') ||
|
||
safeIsA(code, 'head')
|
||
|
||
if (isShield) {
|
||
return [...gem.shieldMods]
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
/**
|
||
* Calculates maximum allowed sockets for an item based on base gemsockets,
|
||
* item type socket tier limits, and item level (ilvl).
|
||
*
|
||
* Tier thresholds from `ItemTypes.txt`:
|
||
* - `ilvl 1..24`: `maxSock1`
|
||
* - `ilvl 25..39`: `maxSock25`
|
||
* - `ilvl 40+`: `maxSock40`
|
||
* - Allowed sockets = `Math.min(base.gemsockets, itemType.maxSockN)`.
|
||
*
|
||
* @param baseGemsockets - Max sockets supported by base item definition (`base.gemsockets`).
|
||
* @param itemType - Item type definition containing `maxSock1`, `maxSock25`, `maxSock40`.
|
||
* @param ilvl - Item level (monster level / drop level).
|
||
* @returns Allowed maximum socket count for the given ilvl tier.
|
||
*/
|
||
export function getMaxSockets(
|
||
baseGemsockets: number,
|
||
itemType: ItemTypeDefinition,
|
||
ilvl: number,
|
||
): number {
|
||
if (baseGemsockets <= 0) return 0
|
||
|
||
let typeMax: number
|
||
if (ilvl <= 24) {
|
||
typeMax = itemType.maxSock1 ?? 0
|
||
} else if (ilvl <= 39) {
|
||
typeMax = itemType.maxSock25 ?? 0
|
||
} else {
|
||
typeMax = itemType.maxSock40 ?? 0
|
||
}
|
||
|
||
return Math.max(0, Math.min(baseGemsockets, typeMax))
|
||
}
|