import { describe, expect, it } from 'vitest' import { GEM_INFO, RUNE_INFO, getSocketableInfo, getSocketableSlotLines, getSocketableSlotText, isGemCode, isJewelCode, isRuneCode, isSocketableCode, } from '../src/game/gems-data.ts' import { CANONICAL_GEMS_TABLE, canSocketDomainItem, getGemModifiers, getSocketedItemModifiersForHost, isSocketableDomainItem, resolveSocketTargetCategory, socketItemIntoHost, } from '../src/game/gems.ts' import { formatItemTooltip } from '../src/game/item-tooltip.ts' import { ItemQuality, type Item } from '../src/game/items.ts' import { InventoryPanel, applySocketToUiItem, canSocketUiItem, isSocketableUiItem, itemToUiInventoryItem, resolveUiSocketTargetCategory, type UiInventoryItem, } from '../src/ui/inventory.ts' import { WorldPanelsHud } from '../src/ui/world-panels.ts' function createDomainHostItem( category: 'weapon' | 'helm' | 'armor' | 'shield', sockets = 3, quality: ItemQuality = ItemQuality.NORMAL, ): Item { const specs = { weapon: { id: 'crs', name: 'Crystal Sword', kind: 'weapon' as const, type: 'swor', tags: ['weap', 'mele', 'swor'], invWidth: 2, invHeight: 3, minDam: 5, maxDam: 15, defense: 0, reqstr: 43, reqdex: 0, durability: 20, }, helm: { id: 'uap', name: 'Shako', kind: 'armor' as const, type: 'helm', tags: ['armo', 'helm'], invWidth: 2, invHeight: 2, minDam: 0, maxDam: 0, defense: 141, reqstr: 50, reqdex: 0, durability: 12, }, armor: { id: 'xtp', name: 'Mage Plate', kind: 'armor' as const, type: 'tors', tags: ['armo', 'tors'], invWidth: 2, invHeight: 3, minDam: 0, maxDam: 0, defense: 250, reqstr: 55, reqdex: 0, durability: 60, }, shield: { id: 'uit', name: 'Monarch', kind: 'armor' as const, type: 'shld', tags: ['armo', 'shld'], invWidth: 2, invHeight: 3, minDam: 0, maxDam: 0, defense: 148, reqstr: 156, reqdex: 0, durability: 86, }, }[category] return { id: 1, code: specs.id, name: specs.name, quality, level: 80, invWidth: specs.invWidth, invHeight: specs.invHeight, identified: true, sockets, totalSockets: sockets, socketedItems: [], defense: specs.defense > 0 ? specs.defense : undefined, durability: specs.durability, maxDurability: specs.durability, base: { id: specs.id, name: specs.name, kind: specs.kind, type: specs.type, invWidth: specs.invWidth, invHeight: specs.invHeight, maxStack: 1, value: 1000, damage: specs.maxDam, mindam: specs.minDam > 0 ? specs.minDam : undefined, maxdam: specs.maxDam > 0 ? specs.maxDam : undefined, minDam: specs.minDam > 0 ? specs.minDam : undefined, maxDam: specs.maxDam > 0 ? specs.maxDam : undefined, defense: specs.defense, reqstr: specs.reqstr, reqdex: specs.reqdex, tags: specs.tags, level: 40, } as any, rolledProps: [], stats: {}, } as unknown as Item } function createDomainJewel(): Item { return { id: 100, code: 'jew', name: 'Ruby Jewel of Fervor', quality: ItemQuality.MAGIC, level: 58, invWidth: 1, invHeight: 1, identified: true, reqLevel: 58, base: { id: 'jew', name: 'Jewel', kind: 'misc', type: 'jewl', invWidth: 1, invHeight: 1, maxStack: 1, value: 500, damage: 0, defense: 0, tags: ['jewl'], level: 1, } as any, rolledProps: [ { code: 'dmg%', min: 40, max: 40, value: 40 }, { code: 'swing2', min: 15, max: 15, value: 15 }, ], stats: {}, } as unknown as Item } function createDomainGemOrRune(code: string, name: string): Item { const isRune = isRuneCode(code) return { id: 200, code, name, quality: ItemQuality.NORMAL, level: 1, invWidth: 1, invHeight: 1, identified: true, base: { id: code, name, kind: 'misc', type: isRune ? 'rune' : 'gem', invWidth: 1, invHeight: 1, maxStack: 1, value: 500, damage: 0, defense: 0, tags: [isRune ? 'rune' : 'gem'], level: 1, } as any, rolledProps: [], stats: {}, } as unknown as Item } describe('Diablo II v1.13c Socketing System (Issue #507)', () => { describe('1. Jewels: Free socketing with slot-independent modifiers', () => { it('identifies jewel codes and renders socket instruction on un-socketed jewels', () => { expect(isJewelCode('jew')).toBe(true) expect(isJewelCode('jewl')).toBe(true) expect(isSocketableCode('jew')).toBe(true) const jewel = createDomainJewel() expect(isSocketableDomainItem(jewel)).toBe(true) const ttZh = formatItemTooltip(jewel, 'zh') const ttEn = formatItemTooltip(jewel, 'en') expect(ttZh.isSocketable).toBe(true) expect(ttEn.isSocketable).toBe(true) expect(ttZh.lines[0]?.text).toBe('可镶嵌进有凹槽的装备') expect(ttEn.lines[0]?.text).toBe('Can be Inserted into Socketed Items') expect(ttZh.lines.some(l => l.text.includes('+40% 增强伤害'))).toBe(true) expect(ttEn.lines.some(l => l.text.includes('+40% Enhanced Damage'))).toBe(true) }) it('applies identical jewel modifiers across Weapon, Helm, Armor, and Shield', () => { const jewel = createDomainJewel() const categories = ['weapon', 'helm', 'armor', 'shield'] as const for (const cat of categories) { const mods = getSocketedItemModifiersForHost(jewel, cat) expect(mods).toEqual([ { code: 'dmg%', min: 40, max: 40, value: 40 }, { code: 'swing2', min: 15, max: 15, value: 15 }, ]) const host = createDomainHostItem(cat, 2) expect(canSocketDomainItem(jewel, host)).toBe(true) const socketedHost = socketItemIntoHost(host, jewel) expect(socketedHost.socketedItems?.length).toBe(1) const ttZh = formatItemTooltip(socketedHost, 'zh') const ttEn = formatItemTooltip(socketedHost, 'en') expect(ttZh.lines.some(l => l.text.includes('+40% 增强伤害'))).toBe(true) expect(ttZh.lines.some(l => l.text.includes('+15% 增加攻击速度'))).toBe(true) expect(ttEn.lines.some(l => l.text.includes('+40% Enhanced Damage'))).toBe(true) expect(ttEn.lines.some(l => l.text.includes('+15% Increased Attack Speed'))).toBe(true) expect(ttZh.reqLevel).toBe(58) } }) it('applies UI jewel socketing identically to Weapon, Helm, Armor, and Shield and updates base stats', () => { const jewelUi: UiInventoryItem = { id: 'jew-40-15', code: 'jew', invFile: 'invjw1', name: 'Ruby Jewel of Fervor', nameZh: '红宝石之急速珠宝', baseNameZh: '珠宝', baseNameEn: 'Jewel', quality: 'magic', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 58, stats: [ { text: '可镶嵌进有凹槽的装备', color: 'white' }, { text: '+40% 增强伤害', color: 'blue' }, { text: '+15% 增加攻击速度', color: 'blue' }, { text: '需求 -15%', color: 'blue' }, ], statsEn: [ { text: 'Can be Inserted into Socketed Items', color: 'white' }, { text: '+40% Enhanced Damage', color: 'blue' }, { text: '+15% Increased Attack Speed', color: 'blue' }, { text: 'Requirements -15%', color: 'blue' }, ], } expect(isSocketableUiItem(jewelUi)).toBe(true) const categories = ['weapon', 'helm', 'armor', 'shield'] as const for (const cat of categories) { const hostUi = itemToUiInventoryItem(createDomainHostItem(cat, 2)) expect(resolveUiSocketTargetCategory(hostUi)).toBe(cat) expect(canSocketUiItem(jewelUi, hostUi)).toBe(true) const origReqStr = hostUi.reqStr! applySocketToUiItem(hostUi, jewelUi) expect(hostUi.socketedItems?.length).toBe(1) expect(hostUi.reqLevel).toBe(58) expect(hostUi.reqStr).toBe(Math.floor((origReqStr * 85) / 100)) expect(hostUi.stats.some(s => s.text === '+40% 增强伤害')).toBe(true) expect(hostUi.stats.some(s => s.text === '+15% 增加攻击速度')).toBe(true) expect(hostUi.stats.some(s => s.text.includes('可镶嵌进有凹槽的装备'))).toBe(false) expect(hostUi.statsEn?.some(s => s.text === '+40% Enhanced Damage')).toBe(true) expect(hostUi.statsEn?.some(s => s.text === '+15% Increased Attack Speed')).toBe(true) if (cat === 'weapon') { // 5-15 with +40% ED -> 7 - 21 expect(hostUi.damage).toBe('7 - 21') } } }) }) describe('2. Gems & Runes: Slot-dependent modifiers across Weapon, Helm, Armor, and Shield', () => { it('loads all 68 canonical Gems.txt entries (35 Gems + 33 Runes) with valid slot modifiers', () => { const allCodes = [...Object.keys(GEM_INFO), ...Object.keys(RUNE_INFO)] expect(allCodes.length).toBe(68) expect(CANONICAL_GEMS_TABLE.length).toBe(68) for (const code of allCodes) { expect(isSocketableCode(code)).toBe(true) expect(isGemCode(code) || isRuneCode(code)).toBe(true) for (const cat of ['weapon', 'helm', 'armor', 'shield'] as const) { const mods = getGemModifiers(code, cat) expect(mods.length).toBeGreaterThan(0) const zhLines = getSocketableSlotLines(code, cat, 'zh') const enLines = getSocketableSlotLines(code, cat, 'en') expect(zhLines.length).toBeGreaterThan(0) expect(enLines.length).toBeGreaterThan(0) } } }) it('applies distinct slot-specific modifiers for Perfect Gems across Weapon, Helm, Armor, and Shield', () => { // Perfect Topaz (gpy): Weapon = 1-40 Lightning Dmg, Helm/Armor = +24% MF, Shield = +40% Lightning Res expect(getSocketableSlotText('gpy', 'weapon', 'zh')).toContain('1-40 闪电伤害') expect(getSocketableSlotText('gpy', 'helm', 'zh')).toContain('24% 获得魔法装备几率') expect(getSocketableSlotText('gpy', 'armor', 'zh')).toContain('24% 获得魔法装备几率') expect(getSocketableSlotText('gpy', 'shield', 'zh')).toContain('抗电 +40%') // Perfect Diamond (gpw): Weapon = +68% Undead Dmg, Helm/Armor = +100 AR, Shield = All Res +19 expect(getSocketableSlotText('gpw', 'weapon', 'en')).toContain('+68% Damage to Undead') expect(getSocketableSlotText('gpw', 'helm', 'en')).toContain('+100 to Attack Rating') expect(getSocketableSlotText('gpw', 'armor', 'en')).toContain('+100 to Attack Rating') expect(getSocketableSlotText('gpw', 'shield', 'en')).toContain('All Resistances +19') // Perfect Ruby (gpr): Weapon = 15-20 Fire Dmg, Helm/Armor = +38 Life, Shield = +40% Fire Res expect(getSocketableSlotText('gpr', 'weapon', 'zh')).toContain('15-20 火焰伤害') expect(getSocketableSlotText('gpr', 'helm', 'zh')).toContain('+38 生命') expect(getSocketableSlotText('gpr', 'shield', 'zh')).toContain('抗火 +40%') // Perfect Amethyst (gpv): Weapon = +150 AR, Helm/Armor = +10 Str, Shield = +30 Defense const shieldWithAmethyst = socketItemIntoHost( createDomainHostItem('shield', 2), createDomainGemOrRune('gpv', 'Perfect Amethyst'), ) const shieldTt = formatItemTooltip(shieldWithAmethyst, 'zh') expect(shieldTt.defense).toBe(148 + 30) }) it('applies distinct slot-specific modifiers for Runes (Um, Ber, Jah, Cham, Hel, Ohm, Zod)', () => { // Um Rune (r22): Weapon = 25% Open Wounds, Helm/Armor = All Res +15, Shield = All Res +22 expect(getSocketableSlotText('r22', 'weapon', 'zh')).toContain('25% 几率撕开伤口') expect(getSocketableSlotText('r22', 'helm', 'zh')).toContain('所有抗性 +15') expect(getSocketableSlotText('r22', 'armor', 'zh')).toContain('所有抗性 +15') expect(getSocketableSlotText('r22', 'shield', 'zh')).toContain('所有抗性 +22') // Ber Rune (r30): Weapon = 20% Crushing Blow, Helm/Armor/Shield = Damage Reduced by 8% expect(getSocketableSlotText('r30', 'weapon', 'en')).toContain('20% Chance of Crushing Blow') expect(getSocketableSlotText('r30', 'helm', 'en')).toContain('Damage Reduced by 8%') expect(getSocketableSlotText('r30', 'shield', 'en')).toContain('Damage Reduced by 8%') // Jah Rune (r31): Weapon = Ignore Target's Defense, Helm/Armor = +5% Max Life, Shield = +50 Life expect(getSocketableSlotText('r31', 'weapon', 'en')).toContain("Ignore Target's Defense") expect(getSocketableSlotText('r31', 'helm', 'en')).toContain('Increase Maximum Life 5%') expect(getSocketableSlotText('r31', 'shield', 'en')).toContain('+50 to Life') // Hel Rune (r15): Weapon = Requirements -20%, Helm/Armor/Shield = Requirements -15% const weaponUi = itemToUiInventoryItem(createDomainHostItem('weapon', 2)) const shieldUi = itemToUiInventoryItem(createDomainHostItem('shield', 2)) const helUi = itemToUiInventoryItem(createDomainGemOrRune('r15', 'Hel Rune')) applySocketToUiItem(weaponUi, helUi) applySocketToUiItem(shieldUi, helUi) expect(weaponUi.reqStr).toBe(Math.floor((43 * 80) / 100)) expect(shieldUi.reqStr).toBe(Math.floor((156 * 85) / 100)) // Zod Rune (r33): Indestructible removes durability const helmUi = itemToUiInventoryItem(createDomainHostItem('helm', 1)) const zodUi = itemToUiInventoryItem(createDomainGemOrRune('r33', 'Zod Rune')) expect(helmUi.durability).toBeDefined() applySocketToUiItem(helmUi, zodUi) expect(helmUi.durability).toBeUndefined() expect(helmUi.reqLevel).toBe(69) expect(helmUi.stats.some(s => s.text.includes('无法破坏'))).toBe(true) }) }) describe('3. Interactive UI Socketing (Inventory, Equipped Slots, Stash, Cube) & Runeword Activation', () => { it('sockets Gems, Runes, and Jewels via Left-Click in Inventory Grid and Equipped Paperdoll Slots', () => { const inv = new InventoryPanel() inv.visible = true inv.gridItems = [] // 1. Place a 2-socket Shako in the 10x4 Inventory grid at (0, 0) const shako = itemToUiInventoryItem(createDomainHostItem('helm', 2, ItemQuality.UNIQUE)) inv.gridItems.push({ item: shako, col: 0, row: 0 }) // Hold a Perfect Topaz on cursor and left-click (0, 0) const pTopaz = itemToUiInventoryItem(createDomainGemOrRune('gpy', 'Perfect Topaz')) inv.cursorItem = pTopaz expect(inv.clickGridCell(0, 0)).toBe(true) expect(inv.cursorItem).toBeNull() expect(shako.socketedItems?.length).toBe(1) expect(shako.stats.some(s => s.text.includes('24% 获得魔法装备几率'))).toBe(true) // 2. Equip Shako into 'helm' slot, hold an Um Rune, and left-click the 'helm' slot inv.equipped.helm = shako const umRune = itemToUiInventoryItem(createDomainGemOrRune('r22', 'Um Rune')) inv.cursorItem = umRune expect(inv.clickEquipSlot('helm')).toBe(true) expect(inv.cursorItem).toBeNull() expect(shako.socketedItems?.length).toBe(2) expect(shako.stats.some(s => s.text.includes('所有抗性 +15'))).toBe(true) // 3. When all 2 sockets are full, clicking the grid cell with another gem swaps instead of socketing const extraGem = itemToUiInventoryItem(createDomainGemOrRune('gpr', 'Perfect Ruby')) inv.cursorItem = extraGem expect(canSocketUiItem(extraGem, shako)).toBe(false) expect(inv.clickGridCell(0, 0)).toBe(true) expect(inv.cursorItem).toBe(shako) expect(inv.gridItems[0]?.item).toBe(extraGem) }) it('sockets items via Left-Click inside Private Stash (6x8) and Horadric Cube (3x4)', () => { const worldPanels = new WorldPanelsHud() const shieldInStash = itemToUiInventoryItem(createDomainHostItem('shield', 1)) const weaponInCube = itemToUiInventoryItem(createDomainHostItem('weapon', 1)) worldPanels.stashItems = [{ item: shieldInStash, col: 0, row: 0 }] worldPanels.cubeItems = [{ item: weaponInCube, col: 0, row: 0 }] // Socket Perfect Diamond into Shield in Stash -> All Resistances +19 const pDiamond = itemToUiInventoryItem(createDomainGemOrRune('gpw', 'Perfect Diamond')) const stashRes = worldPanels.clickStashCell(0, 0, pDiamond) expect(stashRes.handled).toBe(true) expect(stashRes.newCursorItem).toBeNull() expect(shieldInStash.socketedItems?.length).toBe(1) expect(shieldInStash.stats.some(s => s.text.includes('所有抗性 +19'))).toBe(true) // Socket the same Perfect Diamond code into Weapon in Cube -> +68% Damage to Undead const pDiamond2 = itemToUiInventoryItem(createDomainGemOrRune('gpw', 'Perfect Diamond')) const cubeRes = worldPanels.clickCubeCell(0, 0, pDiamond2) expect(cubeRes.handled).toBe(true) expect(cubeRes.newCursorItem).toBeNull() expect(weaponInCube.socketedItems?.length).toBe(1) expect(weaponInCube.stats.some(s => s.text.includes('+68% 对不死生物的伤害'))).toBe(true) }) it('activates Runeword when all sockets of a normal item are filled with a valid rune sequence', () => { // 4-socket Monarch + Tal (r07) + Thul (r10) + Ort (r09) + Amn (r11) -> Spirit const monarch = itemToUiInventoryItem(createDomainHostItem('shield', 4, ItemQuality.NORMAL)) const runeCodes = ['r07', 'r10', 'r09', 'r11'] as const for (const code of runeCodes) { const info = getSocketableInfo(code)! const runeUi = itemToUiInventoryItem(createDomainGemOrRune(code, info.nameEn)) applySocketToUiItem(monarch, runeUi) } expect(monarch.socketedItems?.length).toBe(4) expect(monarch.quality).toBe('rune') expect(monarch.runewordRunes).toBe("'TalThulOrtAmn'") expect(monarch.name).toBe('Spirit') expect(monarch.nameZh).toContain('精神') expect(monarch.stats.some(s => s.text.includes('+2 所有技能'))).toBe(true) expect(monarch.statsEn?.some(s => s.text.includes('+2 to All Skills'))).toBe(true) }) }) })