From aff10832c3429ceb434b081d2891a7fc671945b5 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:48:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=AE=9E=E7=8E=B0=E5=AE=9D?= =?UTF-8?q?=E7=9F=B3=E4=B8=8E=E7=AC=A6=E6=96=87=E9=95=B6=E5=B5=8C=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=20Gems.txt=20(Issue=20#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/gems.ts | 449 +++++++++++++++++++++++++++++++++++++ tests/gems.test.ts | 535 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 984 insertions(+) create mode 100644 src/game/gems.ts create mode 100644 tests/gems.test.ts diff --git a/src/game/gems.ts b/src/game/gems.ts new file mode 100644 index 0000000..6d324d8 --- /dev/null +++ b/src/game/gems.ts @@ -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 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)) +} diff --git a/tests/gems.test.ts b/tests/gems.test.ts new file mode 100644 index 0000000..c72bd91 --- /dev/null +++ b/tests/gems.test.ts @@ -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) + }) + }) +})