diablo2-web/tests/socket-system.test.ts

473 lines
18 KiB
TypeScript

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