diablo2-web/tests/gems.test.ts

536 lines
18 KiB
TypeScript

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