diablo2-web/src/game/gems.ts

450 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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))
}