/** * 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 extends Map { 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 /** Map of gems/runes indexed by name (case-insensitive). */ readonly byName: ReadonlyMap /** 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 /** 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() const byName = new CaseInsensitiveMap() 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 { 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 { 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)) }