feat(items): 实现宝石与符文镶嵌属性 Gems.txt (Issue #106)

This commit is contained in:
troytt 2026-09-18 11:49:02 +00:00
commit 8b61ed059f
2 changed files with 984 additions and 0 deletions

449
src/game/gems.ts Normal file
View File

@ -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))
}

535
tests/gems.test.ts Normal file
View File

@ -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)
})
})
})