feat(items): 实现宝石与符文镶嵌属性 Gems.txt (Issue #106)
This commit is contained in:
commit
8b61ed059f
|
|
@ -0,0 +1,449 @@
|
|||
/**
|
||||
* 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))
|
||||
}
|
||||
|
|
@ -0,0 +1,535 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import * as fs from 'fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import type { D2Table } from '../src/game/acts.ts'
|
||||
import { parseTable, cell } from '../src/game/acts.ts'
|
||||
import {
|
||||
parseGemsTable,
|
||||
loadGems,
|
||||
getGemModifiers,
|
||||
getMaxSockets,
|
||||
parseParam,
|
||||
parseNumber,
|
||||
GEMS_TABLE_PATH,
|
||||
type GemTable,
|
||||
type Gem,
|
||||
} from '../src/game/gems.ts'
|
||||
import {
|
||||
loadItemTypes,
|
||||
isA,
|
||||
type ItemTypeTable,
|
||||
type ItemTypeDefinition,
|
||||
} from '../src/game/item-types.ts'
|
||||
|
||||
const hasD2 = fs.existsSync('samples/d2/d2data.mpq') && fs.existsSync('samples/d2/Patch_D2.mpq')
|
||||
|
||||
/**
|
||||
* Minimal mock D2Table containing Chipped Amethyst, Expansion row, and El Rune.
|
||||
*/
|
||||
function createMinimalMockGemsTable(): D2Table {
|
||||
const header = [
|
||||
'name', 'letter', 'transform', 'code', 'nummods',
|
||||
'weaponMod1Code', 'weaponMod1Param', 'weaponMod1Min', 'weaponMod1Max',
|
||||
'weaponMod2Code', 'weaponMod2Param', 'weaponMod2Min', 'weaponMod2Max',
|
||||
'weaponMod3Code', 'weaponMod3Param', 'weaponMod3Min', 'weaponMod3Max',
|
||||
'helmMod1Code', 'helmMod1Param', 'helmMod1Min', 'helmMod1Max',
|
||||
'helmMod2Code', 'helmMod2Param', 'helmMod2Min', 'helmMod2Max',
|
||||
'helmMod3Code', 'helmMod3Param', 'helmMod3Min', 'helmMod3Max',
|
||||
'shieldMod1Code', 'shieldMod1Param', 'shieldMod1Min', 'shieldMod1Max',
|
||||
'shieldMod2Code', 'shieldMod2Param', 'shieldMod2Min', 'shieldMod2Max',
|
||||
'shieldMod3Code', 'shieldMod3Param', 'shieldMod3Min', 'shieldMod3Max',
|
||||
]
|
||||
|
||||
const rows: string[][] = [
|
||||
// 0: Chipped Amethyst (gcv)
|
||||
[
|
||||
'Chipped Amethyst', '', '18', 'gcv', '3',
|
||||
'att', '0', '40', '40',
|
||||
'', '', '', '',
|
||||
'', '0', '0', '0',
|
||||
'str', '0', '3', '3',
|
||||
'', '0', '0', '0',
|
||||
'', '0', '0', '0',
|
||||
'ac', '0', '8', '8',
|
||||
'', '0', '0', '0',
|
||||
'', '0', '0', '0',
|
||||
],
|
||||
// 1: Expansion row (separator to be filtered out)
|
||||
[
|
||||
'Expansion', '', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '',
|
||||
],
|
||||
// 2: El Rune (r01)
|
||||
[
|
||||
'El Rune', 'El', '18', 'r01', '1',
|
||||
'light', '', '1', '1',
|
||||
'att', '', '50', '50',
|
||||
'', '', '', '',
|
||||
'light', '', '1', '1',
|
||||
'ac', '', '15', '15',
|
||||
'', '', '', '',
|
||||
'light', '', '1', '1',
|
||||
'ac', '', '15', '15',
|
||||
'', '', '', '',
|
||||
],
|
||||
]
|
||||
|
||||
return { header, rows }
|
||||
}
|
||||
|
||||
describe('Gems and Runes Socketing (Issue #106)', () => {
|
||||
describe('Helper functions: parseParam & parseNumber', () => {
|
||||
it('parses empty and whitespace strings as undefined in parseParam', () => {
|
||||
expect(parseParam(undefined)).toBeUndefined()
|
||||
expect(parseParam('')).toBeUndefined()
|
||||
expect(parseParam(' ')).toBeUndefined()
|
||||
expect(parseParam('\t')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses numeric strings as numbers in parseParam', () => {
|
||||
expect(parseParam('0')).toBe(0)
|
||||
expect(parseParam('125')).toBe(125)
|
||||
expect(parseParam('-20')).toBe(-20)
|
||||
expect(typeof parseParam('125')).toBe('number')
|
||||
})
|
||||
|
||||
it('preserves non-numeric strings in parseParam', () => {
|
||||
expect(parseParam('Oak Sage')).toBe('Oak Sage')
|
||||
expect(parseParam('cold')).toBe('cold')
|
||||
})
|
||||
|
||||
it('parses numbers with fallback in parseNumber', () => {
|
||||
expect(parseNumber(undefined)).toBe(0)
|
||||
expect(parseNumber('')).toBe(0)
|
||||
expect(parseNumber('', 10)).toBe(10)
|
||||
expect(parseNumber('40')).toBe(40)
|
||||
expect(parseNumber('-15')).toBe(-15)
|
||||
expect(parseNumber('invalid', 99)).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Standalone Table Parsing', () => {
|
||||
it('parses D2Table and filters Expansion row', () => {
|
||||
const mockTable = createMinimalMockGemsTable()
|
||||
const gemTable = parseGemsTable(mockTable)
|
||||
|
||||
expect(gemTable.length).toBe(2)
|
||||
expect(gemTable.all.length).toBe(2)
|
||||
|
||||
const gcv = gemTable.get('gcv')
|
||||
expect(gcv).toBeDefined()
|
||||
expect(gcv?.name).toBe('Chipped Amethyst')
|
||||
expect(gcv?.isGem).toBe(true)
|
||||
expect(gcv?.isRune).toBe(false)
|
||||
expect(gcv?.transform).toBe(18)
|
||||
expect(gcv?.nummods).toBe(3)
|
||||
|
||||
expect(gcv?.weaponMods).toHaveLength(1)
|
||||
expect(gcv?.weaponMods[0]).toEqual({ code: 'att', param: 0, par: 0, min: 40, max: 40 })
|
||||
|
||||
expect(gcv?.helmMods).toHaveLength(1)
|
||||
expect(gcv?.helmMods[0]).toEqual({ code: 'str', param: 0, par: 0, min: 3, max: 3 })
|
||||
|
||||
expect(gcv?.shieldMods).toHaveLength(1)
|
||||
expect(gcv?.shieldMods[0]).toEqual({ code: 'ac', param: 0, par: 0, min: 8, max: 8 })
|
||||
|
||||
const r01 = gemTable.get('r01')
|
||||
expect(r01).toBeDefined()
|
||||
expect(r01?.name).toBe('El Rune')
|
||||
expect(r01?.letter).toBe('El')
|
||||
expect(r01?.isGem).toBe(false)
|
||||
expect(r01?.isRune).toBe(true)
|
||||
expect(r01?.weaponMods).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('supports TSV string and Uint8Array parsing', () => {
|
||||
const tsv = [
|
||||
'name\tletter\ttransform\tcode\tnummods\tweaponMod1Code\tweaponMod1Param\tweaponMod1Min\tweaponMod1Max\thelmMod1Code\thelmMod1Param\thelmMod1Min\thelmMod1Max\tshieldMod1Code\tshieldMod1Param\tshieldMod1Min\tshieldMod1Max',
|
||||
'Chipped Ruby\t\t1\tgcr\t3\tfire-min\t0\t3\t3\thp\t0\t10\t10\tres-fire\t0\t12\t12',
|
||||
].join('\n')
|
||||
|
||||
const fromString = parseGemsTable(tsv)
|
||||
expect(fromString.length).toBe(1)
|
||||
expect(fromString.get('gcr')?.name).toBe('Chipped Ruby')
|
||||
|
||||
const fromBytes = parseGemsTable(new TextEncoder().encode(tsv))
|
||||
expect(fromBytes.length).toBe(1)
|
||||
expect(fromBytes.get('gcr')?.name).toBe('Chipped Ruby')
|
||||
})
|
||||
|
||||
it('supports case-insensitive code and name lookup, iteration, and indexing', () => {
|
||||
const mockTable = createMinimalMockGemsTable()
|
||||
const table = parseGemsTable(mockTable)
|
||||
|
||||
expect(table.get('GCV')?.name).toBe('Chipped Amethyst')
|
||||
expect(table.get('chipped amethyst')?.code).toBe('gcv')
|
||||
expect(table.byCode.get('R01')?.name).toBe('El Rune')
|
||||
expect(table.byName.get('EL RUNE')?.code).toBe('r01')
|
||||
|
||||
const codes = [...table].map(g => g.code)
|
||||
expect(codes).toEqual(['gcv', 'r01'])
|
||||
|
||||
expect(table[0]?.code).toBe('gcv')
|
||||
expect(table[1]?.code).toBe('r01')
|
||||
expect(table[2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Socket Modifier Dispatch (getGemModifiers)', () => {
|
||||
const mockTable = createMinimalMockGemsTable()
|
||||
const gemTable = parseGemsTable(mockTable)
|
||||
|
||||
const mockIsA = (child: string, parent: string): boolean => {
|
||||
if (child === 'swor' && parent === 'weap') return true
|
||||
if (child === 'axe' && parent === 'weap') return true
|
||||
if (child === 'phlm' && parent === 'helm') return true
|
||||
if (child === 'circ' && parent === 'helm') return true
|
||||
if (child === 'ashd' && parent === 'shld') return true
|
||||
if (child === 'head' && parent === 'shld') return true
|
||||
return false
|
||||
}
|
||||
|
||||
it('dispatches weaponMod1..3 for weapons', () => {
|
||||
const sworMods = getGemModifiers('gcv', 'swor', mockIsA, gemTable)
|
||||
expect(sworMods).toHaveLength(1)
|
||||
expect(sworMods[0]?.code).toBe('att')
|
||||
expect(sworMods[0]?.min).toBe(40)
|
||||
expect(sworMods[0]?.max).toBe(40)
|
||||
|
||||
const swordLiteralMods = getGemModifiers('gcv', 'sword', mockIsA, gemTable)
|
||||
expect(swordLiteralMods).toEqual(sworMods)
|
||||
|
||||
const axeMods = getGemModifiers('gcv', 'axe', mockIsA, gemTable)
|
||||
expect(axeMods).toEqual(sworMods)
|
||||
})
|
||||
|
||||
it('dispatches helmMod1..3 for helmets AND body armor (tors)', () => {
|
||||
// Direct helm
|
||||
const helmMods = getGemModifiers('gcv', 'helm', mockIsA, gemTable)
|
||||
expect(helmMods).toHaveLength(1)
|
||||
expect(helmMods[0]?.code).toBe('str')
|
||||
expect(helmMods[0]?.min).toBe(3)
|
||||
expect(helmMods[0]?.max).toBe(3)
|
||||
|
||||
// Primal helm (barbarian)
|
||||
const phlmMods = getGemModifiers('gcv', 'phlm', mockIsA, gemTable)
|
||||
expect(phlmMods).toEqual(helmMods)
|
||||
|
||||
// CRITICAL: Body armor ('tors') uses helm modifiers!
|
||||
const torsoMods = getGemModifiers('gcv', 'tors', mockIsA, gemTable)
|
||||
expect(torsoMods).toHaveLength(1)
|
||||
expect(torsoMods[0]?.code).toBe('str')
|
||||
expect(torsoMods[0]?.min).toBe(3)
|
||||
expect(torsoMods[0]?.max).toBe(3)
|
||||
|
||||
// Literal 'torso'
|
||||
const torsoLiteralMods = getGemModifiers('gcv', 'torso', mockIsA, gemTable)
|
||||
expect(torsoLiteralMods).toEqual(helmMods)
|
||||
})
|
||||
|
||||
it('dispatches shieldMod1..3 for shields', () => {
|
||||
const shieldMods = getGemModifiers('gcv', 'shie', mockIsA, gemTable)
|
||||
expect(shieldMods).toHaveLength(1)
|
||||
expect(shieldMods[0]?.code).toBe('ac')
|
||||
expect(shieldMods[0]?.min).toBe(8)
|
||||
expect(shieldMods[0]?.max).toBe(8)
|
||||
|
||||
const shldMods = getGemModifiers('gcv', 'shld', mockIsA, gemTable)
|
||||
expect(shldMods).toEqual(shieldMods)
|
||||
|
||||
const ashdMods = getGemModifiers('gcv', 'ashd', mockIsA, gemTable)
|
||||
expect(ashdMods).toEqual(shieldMods)
|
||||
|
||||
const headMods = getGemModifiers('gcv', 'head', mockIsA, gemTable)
|
||||
expect(headMods).toEqual(shieldMods)
|
||||
})
|
||||
|
||||
it('verifies Chipped Amethyst (gcv): att 40 on sword, str 3 on helm, str 3 on torso, ac 8 on shield', () => {
|
||||
const sword = getGemModifiers('gcv', 'swor', mockIsA, gemTable)
|
||||
expect(sword).toEqual([
|
||||
expect.objectContaining({ code: 'att', min: 40, max: 40 }),
|
||||
])
|
||||
|
||||
const helm = getGemModifiers('gcv', 'helm', mockIsA, gemTable)
|
||||
expect(helm).toEqual([
|
||||
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
|
||||
])
|
||||
|
||||
const torso = getGemModifiers('gcv', 'tors', mockIsA, gemTable)
|
||||
expect(torso).toEqual([
|
||||
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
|
||||
])
|
||||
|
||||
const shield = getGemModifiers('gcv', 'shie', mockIsA, gemTable)
|
||||
expect(shield).toEqual([
|
||||
expect.objectContaining({ code: 'ac', min: 8, max: 8 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array for non-socketable item types or unknown gems', () => {
|
||||
expect(getGemModifiers('gcv', 'boot', mockIsA, gemTable)).toEqual([])
|
||||
expect(getGemModifiers('gcv', 'glov', mockIsA, gemTable)).toEqual([])
|
||||
expect(getGemModifiers('gcv', 'belt', mockIsA, gemTable)).toEqual([])
|
||||
expect(getGemModifiers('gcv', 'ring', mockIsA, gemTable)).toEqual([])
|
||||
expect(getGemModifiers('unknown', 'swor', mockIsA, gemTable)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Max Sockets Calculation (getMaxSockets)', () => {
|
||||
const swordType: ItemTypeDefinition = {
|
||||
code: 'swor',
|
||||
name: 'Sword',
|
||||
equiv1: 'weap',
|
||||
equiv2: '',
|
||||
treasureClass: 0,
|
||||
rarity: 0,
|
||||
maxSock1: 3,
|
||||
maxSock25: 4,
|
||||
maxSock40: 6,
|
||||
magic: false,
|
||||
rare: false,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'rarm',
|
||||
bodyLoc2: 'larm',
|
||||
beltable: false,
|
||||
class: '',
|
||||
}
|
||||
|
||||
const helmType: ItemTypeDefinition = {
|
||||
code: 'helm',
|
||||
name: 'Helm',
|
||||
equiv1: 'armo',
|
||||
equiv2: '',
|
||||
treasureClass: 0,
|
||||
rarity: 0,
|
||||
maxSock1: 2,
|
||||
maxSock25: 2,
|
||||
maxSock40: 3,
|
||||
magic: false,
|
||||
rare: false,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'head',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
}
|
||||
|
||||
it('calculates max sockets correctly across ilvl tiers (ilvl 24, 25, 39, 40)', () => {
|
||||
// Crystal Sword (base gemsockets = 6)
|
||||
expect(getMaxSockets(6, swordType, 1)).toBe(3)
|
||||
expect(getMaxSockets(6, swordType, 24)).toBe(3)
|
||||
expect(getMaxSockets(6, swordType, 25)).toBe(4)
|
||||
expect(getMaxSockets(6, swordType, 39)).toBe(4)
|
||||
expect(getMaxSockets(6, swordType, 40)).toBe(6)
|
||||
expect(getMaxSockets(6, swordType, 85)).toBe(6)
|
||||
|
||||
// Crown (base gemsockets = 3)
|
||||
expect(getMaxSockets(3, helmType, 1)).toBe(2)
|
||||
expect(getMaxSockets(3, helmType, 24)).toBe(2)
|
||||
expect(getMaxSockets(3, helmType, 25)).toBe(2)
|
||||
expect(getMaxSockets(3, helmType, 39)).toBe(2)
|
||||
expect(getMaxSockets(3, helmType, 40)).toBe(3)
|
||||
})
|
||||
|
||||
it('correctly caps allowed sockets by base item gemsockets', () => {
|
||||
// Short Sword (base gemsockets = 2)
|
||||
expect(getMaxSockets(2, swordType, 24)).toBe(2)
|
||||
expect(getMaxSockets(2, swordType, 25)).toBe(2)
|
||||
expect(getMaxSockets(2, swordType, 40)).toBe(2)
|
||||
|
||||
// Cap (base gemsockets = 2, helm maxSock40 = 3)
|
||||
expect(getMaxSockets(2, helmType, 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 0 for base gemsockets <= 0', () => {
|
||||
expect(getMaxSockets(0, swordType, 50)).toBe(0)
|
||||
expect(getMaxSockets(-1, swordType, 50)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with samples/d2 MPQs', () => {
|
||||
let rawTable: D2Table
|
||||
let gemTable: GemTable
|
||||
let itemTypes: ItemTypeTable
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!hasD2) return
|
||||
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
|
||||
const bytes = await archives.read(GEMS_TABLE_PATH)
|
||||
rawTable = parseTable(bytes)
|
||||
gemTable = await loadGems(archives)
|
||||
itemTypes = await loadItemTypes(archives)
|
||||
})
|
||||
|
||||
it('verifies GEMS_TABLE_PATH is data\\global\\excel\\Gems.txt', () => {
|
||||
expect(GEMS_TABLE_PATH).toBe('data\\global\\excel\\Gems.txt')
|
||||
})
|
||||
|
||||
it('parses exactly 68 rows from 69 raw rows in 1.13c Gems.txt', () => {
|
||||
if (!hasD2) return
|
||||
|
||||
expect(rawTable.rows.length).toBe(69)
|
||||
expect(rawTable.header.length).toBe(41)
|
||||
|
||||
// Expansion separator row at index 35
|
||||
const expansionRow = rawTable.rows[35]!
|
||||
expect(cell(rawTable, expansionRow, 'name')).toBe('Expansion')
|
||||
expect(cell(rawTable, expansionRow, 'code')).toBe('')
|
||||
|
||||
// Exactly 68 valid rows parsed
|
||||
expect(gemTable.length).toBe(68)
|
||||
expect(gemTable.all.length).toBe(68)
|
||||
expect(gemTable.byCode.size).toBe(68)
|
||||
})
|
||||
|
||||
it('verifies exactly 35 gems and 33 runes', () => {
|
||||
if (!hasD2) return
|
||||
|
||||
expect(gemTable.gems.length).toBe(35)
|
||||
expect(gemTable.runes.length).toBe(33)
|
||||
|
||||
expect(gemTable.gems.every(g => g.isGem && !g.isRune)).toBe(true)
|
||||
expect(gemTable.runes.every(r => r.isRune && !r.isGem)).toBe(true)
|
||||
|
||||
// First gem is Chipped Amethyst (gcv), last gem is Perfect Skull (skz)
|
||||
expect(gemTable.gems[0]?.code).toBe('gcv')
|
||||
expect(gemTable.gems[0]?.name).toBe('Chipped Amethyst')
|
||||
expect(gemTable.gems[34]?.code).toBe('skz')
|
||||
expect(gemTable.gems[34]?.name).toBe('Perfect Skull')
|
||||
|
||||
// First rune is El Rune (r01), last rune is Zod Rune (r33)
|
||||
expect(gemTable.runes[0]?.code).toBe('r01')
|
||||
expect(gemTable.runes[0]?.name).toBe('El Rune')
|
||||
expect(gemTable.runes[32]?.code).toBe('r33')
|
||||
expect(gemTable.runes[32]?.name).toBe('Zod Rune')
|
||||
})
|
||||
|
||||
it('verifies Chipped Amethyst (gcv) modifier values against MPQ data', () => {
|
||||
if (!hasD2) return
|
||||
|
||||
const gcv = gemTable.get('gcv')!
|
||||
expect(gcv).toBeDefined()
|
||||
expect(gcv.name).toBe('Chipped Amethyst')
|
||||
|
||||
// Weapon: att 40
|
||||
expect(gcv.weaponMods).toEqual([
|
||||
expect.objectContaining({ code: 'att', min: 40, max: 40 }),
|
||||
])
|
||||
|
||||
// Helm: str 3
|
||||
expect(gcv.helmMods).toEqual([
|
||||
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
|
||||
])
|
||||
|
||||
// Shield: ac 8
|
||||
expect(gcv.shieldMods).toEqual([
|
||||
expect.objectContaining({ code: 'ac', min: 8, max: 8 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('verifies integration modifier dispatch using real ItemTypes isA', () => {
|
||||
if (!hasD2) return
|
||||
|
||||
const isAPred = (type: string, target: string) => isA(type, target, itemTypes)
|
||||
|
||||
// Sword -> weaponMods
|
||||
const swordMods = getGemModifiers('gcv', 'swor', isAPred, gemTable)
|
||||
expect(swordMods).toEqual([
|
||||
expect.objectContaining({ code: 'att', min: 40, max: 40 }),
|
||||
])
|
||||
|
||||
// Helm -> helmMods
|
||||
const helmMods = getGemModifiers('gcv', 'helm', isAPred, gemTable)
|
||||
expect(helmMods).toEqual([
|
||||
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
|
||||
])
|
||||
|
||||
// Torso -> helmMods (CRITICAL: body armor gets helm mods)
|
||||
const torsoMods = getGemModifiers('gcv', 'tors', isAPred, gemTable)
|
||||
expect(torsoMods).toEqual([
|
||||
expect.objectContaining({ code: 'str', min: 3, max: 3 }),
|
||||
])
|
||||
|
||||
// Shield -> shieldMods
|
||||
const shieldMods = getGemModifiers('gcv', 'shie', isAPred, gemTable)
|
||||
expect(shieldMods).toEqual([
|
||||
expect.objectContaining({ code: 'ac', min: 8, max: 8 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('verifies runes with duration params (Tal Rune r07, Thul Rune r10)', () => {
|
||||
if (!hasD2) return
|
||||
|
||||
const isAPred = (type: string, target: string) => isA(type, target, itemTypes)
|
||||
|
||||
// Tal Rune: poison length 125 frames (5s) on weapon
|
||||
const talWeapon = getGemModifiers('r07', 'swor', isAPred, gemTable)
|
||||
expect(talWeapon).toEqual([
|
||||
expect.objectContaining({ code: 'dmg-pois', param: 125, min: 154, max: 154 }),
|
||||
])
|
||||
|
||||
// Thul Rune: cold length 75 frames (3s) on weapon
|
||||
const thulWeapon = getGemModifiers('r10', 'swor', isAPred, gemTable)
|
||||
expect(thulWeapon).toEqual([
|
||||
expect.objectContaining({ code: 'dmg-cold', param: 75, min: 3, max: 14 }),
|
||||
])
|
||||
|
||||
// Tal Rune on shield: res-pois 35
|
||||
const talShield = getGemModifiers('r07', 'shie', isAPred, gemTable)
|
||||
expect(talShield).toEqual([
|
||||
expect.objectContaining({ code: 'res-pois', min: 35, max: 35 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('verifies real ItemTypes socket tiers with getMaxSockets', () => {
|
||||
if (!hasD2) return
|
||||
|
||||
const sworDef = itemTypes.byCode.get('swor')!
|
||||
expect(sworDef).toBeDefined()
|
||||
expect(sworDef.maxSock1).toBe(3)
|
||||
expect(sworDef.maxSock25).toBe(4)
|
||||
expect(sworDef.maxSock40).toBe(6)
|
||||
|
||||
// Crystal sword (base gemsockets = 6)
|
||||
expect(getMaxSockets(6, sworDef, 24)).toBe(3)
|
||||
expect(getMaxSockets(6, sworDef, 25)).toBe(4)
|
||||
expect(getMaxSockets(6, sworDef, 39)).toBe(4)
|
||||
expect(getMaxSockets(6, sworDef, 40)).toBe(6)
|
||||
|
||||
const helmDef = itemTypes.byCode.get('helm')!
|
||||
expect(helmDef).toBeDefined()
|
||||
expect(helmDef.maxSock1).toBe(2)
|
||||
expect(helmDef.maxSock25).toBe(2)
|
||||
expect(helmDef.maxSock40).toBe(3)
|
||||
|
||||
// Crown (base gemsockets = 3)
|
||||
expect(getMaxSockets(3, helmDef, 24)).toBe(2)
|
||||
expect(getMaxSockets(3, helmDef, 25)).toBe(2)
|
||||
expect(getMaxSockets(3, helmDef, 39)).toBe(2)
|
||||
expect(getMaxSockets(3, helmDef, 40)).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue