feat(socket): implement 1.13c jewel, gem & rune socketing system (Closes #507)

- Jewels ('jew' / 'jewl') can be socketed into any socketed equipment (weapon, helm, armor, shield) and apply their rolled modifiers identically regardless of slot.
- Un-socketed Jewels display 'Can be Inserted into Socketed Items' / '可镶嵌进有凹槽的装备'.
- Gems (35 codes) and Runes (r01..r33, 33 codes) dispatch slot-dependent modifiers (weaponMods vs helmMods/armor vs shieldMods) per 1.13c Gems.txt.
- Recalculate host item defense, weapon damage, reqStr, reqDex, reqLevel, indestructibility, rune letter sequences, and Runeword completion.
- Support left-click interactive socketing and visual socket overlay rendering across Inventory (10x4), Equipped paperdoll slots, Private Stash (6x8), and Horadric Cube (3x4).
- Add unit & parity test suite in tests/socket-system.test.ts.
This commit is contained in:
troytt 2026-09-28 12:44:31 +00:00
parent 6601dcde82
commit 768a2cc920
8 changed files with 1869 additions and 13 deletions

View File

@ -1210,6 +1210,15 @@ export const RUNE_INFO: Record<string, RuneInfo> = {
}
}
/**
* Equipment slot category for socket modifier dispatch in Diablo II 1.13c.
* - 'weapon': Melee/ranged weapons, staves, wands, orbs, scepters, claws -> weaponMod1..3
* - 'helm': Helms, circlets, barbarian helms, druid pelts -> helmMod1..3 (helmsEn/helmsZh)
* - 'armor': Body armor / torso ('tors') -> helmMod1..3 (armorEn/armorZh)
* - 'shield': Shields, paladin auric shields, necromancer shrunken heads -> shieldMod1..3
*/
export type SocketTargetCategory = 'weapon' | 'helm' | 'armor' | 'shield'
/**
* Checks whether an item code is a valid 1.13c Gem.
*/
@ -1226,6 +1235,22 @@ export function isRuneCode(code: string | undefined): boolean {
return Boolean(RUNE_INFO[code.trim().toLowerCase()])
}
/**
* Checks whether an item code is a 1.13c Jewel ('jew' or 'jewl').
*/
export function isJewelCode(code: string | undefined): boolean {
if (!code) return false
const c = code.trim().toLowerCase()
return c === 'jew' || c === 'jewl'
}
/**
* Checks whether an item code is any valid socketable filler (Gem, Rune, or Jewel).
*/
export function isSocketableCode(code: string | undefined): boolean {
return isGemCode(code) || isRuneCode(code) || isJewelCode(code)
}
/**
* Returns canonical socketable information for any gem or rune code.
*/
@ -1287,3 +1312,43 @@ export function getGemColorTransform(code: string | undefined): string | undefin
return COLOR_CODES[idx]
}
/**
* Returns the localized modifier summary string for a Gem or Rune when socketed
* into the specified equipment slot category.
*/
export function getSocketableSlotText(
code: string | undefined,
category: SocketTargetCategory,
locale: 'en' | 'zh' = 'zh',
): string | undefined {
const info = getSocketableInfo(code)
if (!info) return undefined
switch (category) {
case 'weapon':
return locale === 'en' ? info.weaponsEn : info.weaponsZh
case 'helm':
return locale === 'en' ? info.helmsEn : info.helmsZh
case 'armor':
return locale === 'en' ? info.armorEn : info.armorZh
case 'shield':
return locale === 'en' ? info.shieldsEn : info.shieldsZh
}
}
/**
* Returns individual localized stat lines for a Gem or Rune when socketed
* into the specified equipment slot category (splitting comma-separated multi-mods).
*/
export function getSocketableSlotLines(
code: string | undefined,
category: SocketTargetCategory,
locale: 'en' | 'zh' = 'zh',
): string[] {
const text = getSocketableSlotText(code, category, locale)
if (!text) return []
return text
.split(/,\s*/)
.map((s) => s.trim())
.filter(Boolean)
}

View File

@ -29,6 +29,16 @@ 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'
import {
isGemCode,
isRuneCode,
isJewelCode,
getSocketableInfo,
RUNE_INFO,
type SocketTargetCategory,
} from './gems-data.ts'
import { ItemQuality, type Item, type RolledItemProp } from './items.ts'
import { checkRunewordMatch, isNormalQuality } from './runeword.ts'
/** File path of `Gems.txt` inside MPQ archives. */
export const GEMS_TABLE_PATH = 'data\\global\\excel\\Gems.txt'
@ -325,6 +335,83 @@ export async function loadGems(
return parseGemsTable(d2Table)
}
/**
* 1.13c gold standard `Gems.txt` embedded TSV data (68 active gem & rune rows + 1 Expansion separator).
*/
export const CANONICAL_GEMS_TSV = `name\tletter\ttransform\tcode\tnummods\tweaponMod1Code\tweaponMod1Param\tweaponMod1Min\tweaponMod1Max\tweaponMod2Code\tweaponMod2Param\tweaponMod2Min\tweaponMod2Max\tweaponMod3Code\tweaponMod3Param\tweaponMod3Min\tweaponMod3Max\thelmMod1Code\thelmMod1Param\thelmMod1Min\thelmMod1Max\thelmMod2Code\thelmMod2Param\thelmMod2Min\thelmMod2Max\thelmMod3Code\thelmMod3Param\thelmMod3Min\thelmMod3Max\tshieldMod1Code\tshieldMod1Param\tshieldMod1Min\tshieldMod1Max\tshieldMod2Code\tshieldMod2Param\tshieldMod2Min\tshieldMod2Max\tshieldMod3Code\tshieldMod3Param\tshieldMod3Min\tshieldMod3Max
Chipped Amethyst\t\t18\tgcv\t3\tatt\t0\t40\t40\t\t\t\t\t\t0\t0\t0\tstr\t0\t3\t3\t\t0\t0\t0\t\t0\t0\t0\tac\t0\t8\t8\t\t0\t0\t0\t\t0\t0\t0
Flawed Amethyst\t\t18\tgfv\t3\tatt\t0\t60\t60\t\t\t\t\t\t0\t0\t0\tstr\t0\t4\t4\t\t0\t0\t0\t\t0\t0\t0\tac\t0\t12\t12\t\t0\t0\t0\t\t0\t0\t0
Amethyst\t\t18\tgsv\t3\tatt\t0\t80\t80\t\t\t\t\t\t0\t0\t0\tstr\t0\t6\t6\t\t0\t0\t0\t\t0\t0\t0\tac\t0\t18\t18\t\t0\t0\t0\t\t0\t0\t0
Flawless Amethyst\t\t18\tgzv\t3\tatt\t0\t100\t100\t\t\t\t\t\t0\t0\t0\tstr\t0\t8\t8\t\t0\t0\t0\t\t0\t0\t0\tac\t0\t24\t24\t\t0\t0\t0\t\t0\t0\t0
Perfect Amethyst\t\t17\tgpv\t3\tatt\t0\t150\t150\t\t\t\t\t\t0\t0\t0\tstr\t0\t10\t10\t\t0\t0\t0\t\t0\t0\t0\tac\t0\t30\t30\t\t0\t0\t0\t\t0\t0\t0
Chipped Saphire\t\t5\tgcb\t3\tcold-min\t0\t1\t1\tcold-max\t0\t3\t3\tcold-len\t0\t25\t25\tmana\t0\t10\t10\t\t0\t0\t0\t\t0\t0\t0\tres-cold\t0\t12\t12\t\t0\t0\t0\t\t0\t0\t0
Flawed Saphire\t\t5\tgfb\t3\tcold-min\t0\t3\t3\tcold-max\t0\t5\t5\tcold-len\t0\t35\t35\tmana\t0\t17\t17\t\t0\t0\t0\t\t0\t0\t0\tres-cold\t0\t16\t16\t\t0\t0\t0\t\t0\t0\t0
Saphire\t\t5\tgsb\t3\tcold-min\t0\t4\t4\tcold-max\t0\t7\t7\tcold-len\t0\t50\t50\tmana\t0\t24\t24\t\t0\t0\t0\t\t0\t0\t0\tres-cold\t0\t22\t22\t\t0\t0\t0\t\t0\t0\t0
Flwless Saphire\t\t5\tglb\t3\tcold-min\t0\t6\t6\tcold-max\t0\t10\t10\tcold-len\t0\t60\t60\tmana\t0\t31\t31\t\t0\t0\t0\t\t0\t0\t0\tres-cold\t0\t28\t28\t\t0\t0\t0\t\t0\t0\t0
Perfect Saphire\t\t6\tgpb\t3\tcold-min\t0\t10\t10\tcold-max\t0\t14\t14\tcold-len\t0\t75\t75\tmana\t0\t38\t38\t\t0\t0\t0\t\t0\t0\t0\tres-cold\t0\t40\t40\t\t0\t0\t0\t\t0\t0\t0
Chipped Emerald\t\t11\tgcg\t3\tpois-min\t0\t34\t34\tpois-max\t0\t34\t34\tpois-len\t0\t75\t75\tdex\t0\t3\t3\t\t0\t0\t0\t\t0\t0\t0\tres-pois\t0\t12\t12\t\t0\t0\t0\t\t0\t0\t0
Flawed Emerald\t\t11\tgfg\t3\tpois-min\t0\t51\t51\tpois-max\t0\t51\t51\tpois-len\t0\t100\t100\tdex\t0\t4\t4\t\t0\t0\t0\t\t0\t0\t0\tres-pois\t0\t16\t16\t\t0\t0\t0\t\t0\t0\t0
Emerald\t\t11\tgsg\t3\tpois-min\t0\t82\t82\tpois-max\t0\t82\t82\tpois-len\t0\t125\t125\tdex\t0\t6\t6\t\t0\t0\t0\t\t0\t0\t0\tres-pois\t0\t22\t22\t\t0\t0\t0\t\t0\t0\t0
Flawless Emerald\t\t11\tglg\t3\tpois-min\t0\t101\t101\tpois-max\t0\t101\t101\tpois-len\t0\t152\t152\tdex\t0\t8\t8\t\t0\t0\t0\t\t0\t0\t0\tres-pois\t0\t28\t28\t\t0\t0\t0\t\t0\t0\t0
Perfect Emerald\t\t12\tgpg\t3\tpois-min\t0\t143\t143\tpois-max\t0\t143\t143\tpois-len\t0\t179\t179\tdex\t0\t10\t10\t\t0\t0\t0\t\t0\t0\t0\tres-pois\t0\t40\t40\t\t0\t0\t0\t\t0\t0\t0
Chipped Ruby\t\t8\tgcr\t3\tfire-min\t0\t3\t3\tfire-max\t0\t4\t4\t\t0\t0\t0\thp\t0\t10\t10\t\t0\t0\t0\t\t0\t0\t0\tres-fire\t0\t12\t12\t\t0\t0\t0\t\t0\t0\t0
Flawed Ruby\t\t8\tgfr\t3\tfire-min\t0\t5\t5\tfire-max\t0\t8\t8\t\t0\t0\t0\thp\t0\t17\t17\t\t0\t0\t0\t\t0\t0\t0\tres-fire\t0\t16\t16\t\t0\t0\t0\t\t0\t0\t0
Ruby\t\t8\tgsr\t3\tfire-min\t0\t8\t8\tfire-max\t0\t12\t12\t\t0\t0\t0\thp\t0\t24\t24\t\t0\t0\t0\t\t0\t0\t0\tres-fire\t0\t22\t22\t\t0\t0\t0\t\t0\t0\t0
Flawless Ruby\t\t8\tglr\t3\tfire-min\t0\t10\t10\tfire-max\t0\t16\t16\t\t0\t0\t0\thp\t0\t31\t31\t\t0\t0\t0\t\t0\t0\t0\tres-fire\t0\t28\t28\t\t0\t0\t0\t\t0\t0\t0
Perfect Ruby\t\t9\tgpr\t3\tfire-min\t0\t15\t15\tfire-max\t0\t20\t20\t\t0\t0\t0\thp\t0\t38\t38\t\t0\t0\t0\t\t0\t0\t0\tres-fire\t0\t40\t40\t\t0\t0\t0\t\t0\t0\t0
Chipped Diamond\t\t1\tgcw\t3\tdmg-undead\t0\t28\t28\t\t\t\t\t\t0\t0\t0\tatt\t0\t20\t20\t\t0\t0\t0\t\t0\t0\t0\tres-all\t0\t6\t6\t\t0\t0\t0\t\t0\t0\t0
Flawed Diamond\t\t1\tgfw\t3\tdmg-undead\t0\t34\t34\t\t\t\t\t\t0\t0\t0\tatt\t0\t40\t40\t\t0\t0\t0\t\t0\t0\t0\tres-all\t0\t8\t8\t\t0\t0\t0\t\t0\t0\t0
Diamond\t\t1\tgsw\t3\tdmg-undead\t0\t44\t44\t\t\t\t\t\t0\t0\t0\tatt\t0\t60\t60\t\t0\t0\t0\t\t0\t0\t0\tres-all\t0\t11\t11\t\t0\t0\t0\t\t0\t0\t0
Flawless Diamond\t\t1\tglw\t3\tdmg-undead\t0\t54\t54\t\t\t\t\t\t0\t0\t0\tatt\t0\t80\t80\t\t0\t0\t0\t\t0\t0\t0\tres-all\t0\t14\t14\t\t0\t0\t0\t\t0\t0\t0
Perfect Diamond\t\t0\tgpw\t3\tdmg-undead\t0\t68\t68\t\t\t\t\t\t0\t0\t0\tatt\t0\t100\t100\t\t0\t0\t0\t\t0\t0\t0\tres-all\t0\t19\t19\t\t0\t0\t0\t\t0\t0\t0
Chipped Topaz\t\t14\tgcy\t3\tltng-min\t0\t1\t1\tltng-max\t0\t8\t8\t\t0\t0\t0\tmag%\t0\t9\t9\t\t0\t0\t0\t\t0\t0\t0\tres-ltng\t0\t12\t12\t\t0\t0\t0\t\t0\t0\t0
Flawed Topaz\t\t14\tgfy\t3\tltng-min\t0\t1\t1\tltng-max\t0\t14\t14\t\t0\t0\t0\tmag%\t0\t13\t13\t\t0\t0\t0\t\t0\t0\t0\tres-ltng\t0\t16\t16\t\t0\t0\t0\t\t0\t0\t0
Topaz\t\t14\tgsy\t3\tltng-min\t0\t1\t1\tltng-max\t0\t22\t22\t\t0\t0\t0\tmag%\t0\t16\t16\t\t0\t0\t0\t\t0\t0\t0\tres-ltng\t0\t22\t22\t\t0\t0\t0\t\t0\t0\t0
Flawless Topaz\t\t14\tgly\t3\tltng-min\t0\t1\t1\tltng-max\t0\t30\t30\t\t0\t0\t0\tmag%\t0\t20\t20\t\t0\t0\t0\t\t0\t0\t0\tres-ltng\t0\t28\t28\t\t0\t0\t0\t\t0\t0\t0
Perfect Topaz\t\t13\tgpy\t3\tltng-min\t0\t1\t1\tltng-max\t0\t40\t40\t\t0\t0\t0\tmag%\t0\t24\t24\t\t0\t0\t0\t\t0\t0\t0\tres-ltng\t0\t40\t40\t\t0\t0\t0\t\t0\t0\t0
Chipped Skull\t\t2\tskc\t3\tmanasteal\t0\t1\t1\tlifesteal\t0\t2\t2\t\t\t\t\tregen\t0\t2\t2\tregen-mana\t0\t8\t8\t\t0\t0\t0\tthorns\t0\t4\t4\t\t0\t0\t0\t\t0\t0\t0
Flawed Skull\t\t2\tskf\t3\tmanasteal\t0\t2\t2\tlifesteal\t0\t2\t2\t\t\t\t\tregen\t0\t3\t3\tregen-mana\t0\t8\t8\t\t0\t0\t0\tthorns\t0\t8\t8\t\t0\t0\t0\t\t0\t0\t0
Skull\t\t2\tsku\t3\tmanasteal\t0\t2\t2\tlifesteal\t0\t3\t3\t\t\t\t\tregen\t0\t3\t3\tregen-mana\t0\t12\t12\t\t0\t0\t0\tthorns\t0\t12\t12\t\t0\t0\t0\t\t0\t0\t0
Flawless Skull\t\t2\tskl\t3\tmanasteal\t0\t3\t3\tlifesteal\t0\t3\t3\t\t\t\t\tregen\t0\t4\t4\tregen-mana\t0\t12\t12\t\t0\t0\t0\tthorns\t0\t16\t16\t\t0\t0\t0\t\t0\t0\t0
Perfect Skull\t\t3\tskz\t3\tmanasteal\t0\t3\t3\tlifesteal\t0\t4\t4\t\t\t\t\tregen\t0\t5\t5\tregen-mana\t0\t19\t19\t\t0\t0\t0\tthorns\t0\t20\t20\t\t0\t0\t0\t\t0\t0\t0
Expansion\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t
El Rune\tEl\t18\tr01\t1\tlight\t\t1\t1\tatt\t\t50\t50\t\t\t\t\tlight\t\t1\t1\tac\t\t15\t15\t\t\t\t\tlight\t\t1\t1\tac\t\t15\t15\t\t\t\t0
Eld Rune\tEld\t18\tr02\t1\tatt-undead\t\t50\t50\tdmg-undead\t\t75\t75\t\t\t\t\tstamdrain\t\t15\t15\t\t\t\t\t\t\t\t\tblock\t\t7\t7\t\t\t\t\t\t\t\t0
Tir Rune\tTir\t18\tr03\t1\tmana-kill\t\t2\t2\t\t\t\t\t\t\t\t\tmana-kill\t\t2\t2\t\t\t\t\t\t\t\t\tmana-kill\t\t2\t2\t\t\t\t\t\t\t\t0
Nef Rune\tNef\t18\tr04\t1\tknock\t\t1\t1\t\t\t\t\t\t\t\t\tac-miss\t\t30\t30\t\t\t\t\t\t\t\t\tac-miss\t\t30\t30\t\t\t\t\t\t\t\t0
Eth Rune\tEth\t18\tr05\t1\treduce-ac\t\t25\t25\t\t\t\t\t\t\t\t\tregen-mana\t\t15\t15\t\t\t\t\t\t\t\t\tregen-mana\t\t15\t15\t\t\t\t\t\t\t\t0
Ith Rune\tIth\t18\tr06\t1\tdmg-max\t\t9\t9\t\t\t\t\t\t\t\t\tdmg-to-mana\t\t15\t15\t\t\t\t\t\t\t\t\tdmg-to-mana\t\t15\t15\t\t\t\t\t\t\t\t0
Tal Rune\tTal\t18\tr07\t1\tdmg-pois\t125\t154\t154\t\t\t\t\t\t\t\t\tres-pois\t\t30\t30\t\t\t\t\t\t\t\t\tres-pois\t\t35\t35\t\t\t\t\t\t\t\t0
Ral Rune\tRal\t18\tr08\t1\tdmg-fire\t\t5\t30\t\t\t\t\t\t\t\t\tres-fire\t\t30\t30\t\t\t\t\t\t\t\t\tres-fire\t\t35\t35\t\t\t\t\t\t\t\t0
Ort Rune\tOrt\t18\tr09\t1\tdmg-ltng\t\t1\t50\t\t\t\t\t\t\t\t\tres-ltng\t\t30\t30\t\t\t\t\t\t\t\t\tres-ltng\t\t35\t35\t\t\t\t\t\t\t\t0
Thul Rune\tThul\t18\tr10\t1\tdmg-cold\t75\t3\t14\t\t\t\t\t\t\t\t\tres-cold\t\t30\t30\t\t\t\t\t\t\t\t\tres-cold\t\t35\t35\t\t\t\t\t\t\t\t0
Amn Rune\tAmn\t18\tr11\t1\tlifesteal\t\t7\t7\t\t\t\t\t\t\t\t\tthorns\t\t14\t14\t\t\t\t\t\t\t\t\tthorns\t\t14\t14\t\t\t\t\t\t\t\t0
Sol Rune\tSol\t18\tr12\t1\tdmg-min\t\t9\t9\t\t\t\t\t\t\t\t\tred-dmg\t\t7\t7\t\t\t\t\t\t\t\t\tred-dmg\t\t7\t7\t\t\t\t\t\t\t\t0
Shael Rune\tShael\t18\tr13\t1\tswing2\t\t20\t20\t\t\t\t\t\t\t\t\tbalance2\t\t20\t20\t\t\t\t\t\t\t\t\tblock2\t\t20\t20\t\t\t\t\t\t\t\t0
Dol Rune\tDol\t18\tr14\t1\thowl\t\t32\t32\t\t\t\t\t\t\t\t\tregen\t\t7\t7\t\t\t\t\t\t\t\t\tregen\t\t7\t7\t\t\t\t\t\t\t\t0
Hel Rune\tHel\t18\tr15\t1\tease\t\t-20\t-20\t\t\t\t\t\t\t\t\tease\t\t-15\t-15\t\t\t\t\t\t\t\t\tease\t\t-15\t-15\t\t\t\t\t\t\t\t0
Io Rune\tIo\t18\tr16\t1\tvit\t\t10\t10\t\t\t\t\t\t\t\t\tvit\t\t10\t10\t\t\t\t\t\t\t\t\tvit\t\t10\t10\t\t\t\t\t\t\t\t0
Lum Rune\tLum\t18\tr17\t1\tenr\t\t10\t10\t\t\t\t\t\t\t\t\tenr\t\t10\t10\t\t\t\t\t\t\t\t\tenr\t\t10\t10\t\t\t\t\t\t\t\t0
Ko Rune\tKo\t18\tr18\t1\tdex\t\t10\t10\t\t\t\t\t\t\t\t\tdex\t\t10\t10\t\t\t\t\t\t\t\t\tdex\t\t10\t10\t\t\t\t\t\t\t\t0
Fal Rune\tFal\t18\tr19\t1\tstr\t\t10\t10\t\t\t\t\t\t\t\t\tstr\t\t10\t10\t\t\t\t\t\t\t\t\tstr\t\t10\t10\t\t\t\t\t\t\t\t0
Lem Rune\tLem\t18\tr20\t1\tgold%\t\t75\t75\t\t\t\t\t\t\t\t\tgold%\t\t50\t50\t\t\t\t\t\t\t\t\tgold%\t\t50\t50\t\t\t\t\t\t\t\t0
Pul Rune\tPul\t18\tr21\t1\tatt-demon\t\t100\t100\tdmg-demon\t\t75\t75\t\t\t\t\tac%\t\t30\t30\t\t\t\t\t\t\t\t\tac%\t\t30\t30\t\t\t\t\t\t\t\t0
Um Rune\tUm\t18\tr22\t1\topenwounds\t\t25\t25\t\t\t\t\t\t\t\t\tres-all\t\t15\t15\t\t\t\t\t\t\t\t\tres-all\t\t22\t22\t\t\t\t\t\t\t\t0
Mal Rune\tMal\t18\tr23\t1\tnoheal\t\t1\t1\t\t\t\t\t\t\t\t\tred-mag\t\t7\t7\t\t\t\t\t\t\t\t\tred-mag\t\t7\t7\t\t\t\t\t\t\t\t0
Ist Rune\tIst\t18\tr24\t1\tmag%\t\t30\t30\t\t\t\t\t\t\t\t\tmag%\t\t25\t25\t\t\t\t\t\t\t\t\tmag%\t\t25\t25\t\t\t\t\t\t\t\t0
Gul Rune\tGul\t18\tr25\t1\tatt%\t\t20\t20\t\t\t\t\t\t\t\t\tres-pois-max\t\t5\t5\t\t\t\t\t\t\t\t\tres-pois-max\t\t5\t5\t\t\t\t\t\t\t\t0
Vex Rune\tVex\t18\tr26\t1\tmanasteal\t\t7\t7\t\t\t\t\t\t\t\t\tres-fire-max\t\t5\t5\t\t\t\t\t\t\t\t\tres-fire-max\t\t5\t5\t\t\t\t\t\t\t\t0
Ohm Rune\tOhm\t18\tr27\t1\tdmg%\t\t50\t50\t\t\t\t\t\t\t\t\tres-cold-max\t\t5\t5\t\t\t\t\t\t\t\t\tres-cold-max\t\t5\t5\t\t\t\t\t\t\t\t0
Lo Rune\tLo\t18\tr28\t1\tdeadly\t\t20\t20\t\t\t\t\t\t\t\t\tres-ltng-max\t\t5\t5\t\t\t\t\t\t\t\t\tres-ltng-max\t\t5\t5\t\t\t\t\t\t\t\t0
Sur Rune\tSur\t18\tr29\t1\tstupidity\t\t1\t1\t\t\t\t\t\t\t\t\tmana%\t\t5\t5\t\t\t\t\t\t\t\t\tmana\t\t50\t50\t\t\t\t\t\t\t\t0
Ber Rune\tBer\t18\tr30\t1\tcrush\t\t20\t20\t\t\t\t\t\t\t\t\tred-dmg%\t\t8\t8\t\t\t\t\t\t\t\t\tred-dmg%\t\t8\t8\t\t\t\t\t\t\t\t0
Jah Rune\tJah\t18\tr31\t1\tignore-ac\t\t1\t1\t\t\t\t\t\t\t\t\thp%\t\t5\t5\t\t\t\t\t\t\t\t\thp\t\t50\t50\t\t\t\t\t\t\t\t0
Cham Rune\tCham\t18\tr32\t1\tfreeze\t\t3\t3\t\t\t\t\t\t\t\t\tnofreeze\t\t1\t1\t\t\t\t\t\t\t\t\tnofreeze\t\t1\t1\t\t\t\t\t\t\t\t0
Zod Rune\tZod\t18\tr33\t1\tindestruct\t\t1\t1\t\t\t\t\t\t\t\t\tindestruct\t\t1\t1\t\t\t\t\t\t\t\t\tindestruct\t\t1\t1\t\t\t\t\t\t\t\t0`
/** Canonical 1.13c GemTable pre-parsed from `CANONICAL_GEMS_TSV`. */
export const CANONICAL_GEMS_TABLE: GemTable = parseGemsTable(CANONICAL_GEMS_TSV)
/**
* Dispatches gem modifiers based on target item type.
*
@ -338,14 +425,14 @@ export async function loadGems(
* @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.
* @param gemTable - Parsed gem table (defaults to `CANONICAL_GEMS_TABLE`).
* @returns Array of modifiers applied to the socketed item.
*/
export function getGemModifiers(
gemCode: string,
itemTypeCode: string,
isA: IsAPredicate | Function,
gemTable: GemTable,
isA?: IsAPredicate | Function,
gemTable: GemTable = CANONICAL_GEMS_TABLE,
): GemModifier[] {
const gem = gemTable.get(gemCode)
if (!gem) return []
@ -367,6 +454,26 @@ export function getGemModifiers(
code === 'weapon' ||
code === 'swor' ||
code === 'sword' ||
code === 'axe' ||
code === 'mace' ||
code === 'hamm' ||
code === 'club' ||
code === 'pole' ||
code === 'spea' ||
code === 'staf' ||
code === 'wand' ||
code === 'scep' ||
code === 'bow' ||
code === 'xbow' ||
code === 'knif' ||
code === 'dagg' ||
code === 'jave' ||
code === 'h2h' ||
code === 'h2h2' ||
code === 'orb' ||
code === 'abow' ||
code === 'aspe' ||
code === 'ajav' ||
safeIsA(code, 'weap') ||
safeIsA(code, 'mele') ||
safeIsA(code, 'bow') ||
@ -385,6 +492,11 @@ export function getGemModifiers(
code === 'tors' ||
code === 'torso' ||
code === 'body' ||
code === 'armor' ||
code === 'phlm' ||
code === 'pelt' ||
code === 'circ' ||
code === 'cloa' ||
safeIsA(code, 'helm') ||
safeIsA(code, 'tors') ||
safeIsA(code, 'torso') ||
@ -401,6 +513,9 @@ export function getGemModifiers(
code === 'shld' ||
code === 'shie' ||
code === 'shield' ||
code === 'ashd' ||
code === 'head' ||
code === 'pala' ||
safeIsA(code, 'shld') ||
safeIsA(code, 'shie') ||
safeIsA(code, 'shield') ||
@ -414,6 +529,344 @@ export function getGemModifiers(
return []
}
const SHIELD_CODES_AND_TAGS = new Set([
'shld', 'shie', 'shield', 'ashd', 'head', 'pala',
'buc', 'sml', 'lrg', 'kit', 'tow', 'gts', 'bsh', 'spk',
'xuc', 'xml', 'xrg', 'xit', 'xow', 'xts', 'xsh', 'xpk',
'uuc', 'uml', 'urg', 'uit', 'uow', 'uts', 'ush', 'upk',
'pa1', 'pa2', 'pa3', 'pa4', 'pa5', 'pa6', 'pa7', 'pa8', 'pa9', 'paa', 'pab', 'pac', 'pad', 'pae', 'paf',
'ne1', 'ne2', 'ne3', 'ne4', 'ne5', 'ne6', 'ne7', 'ne8', 'ne9', 'nea', 'neb', 'nec', 'ned', 'nee', 'nef',
])
const HELM_CODES_AND_TAGS = new Set([
'helm', 'helmet', 'circ', 'phlm', 'pelt',
'cap', 'skp', 'hlm', 'fhl', 'ghm', 'crn', 'msk', 'bhm',
'xap', 'xkp', 'xlm', 'xhl', 'xhm', 'xrn', 'xsk', 'xh9',
'uap', 'ukp', 'ulm', 'uhl', 'uhm', 'urn', 'usk', 'uh9',
'ci0', 'ci1', 'ci2', 'ci3',
'ba1', 'ba2', 'ba3', 'ba4', 'ba5', 'ba6', 'ba7', 'ba8', 'ba9', 'baa', 'bab', 'bac', 'bad', 'bae', 'baf',
'dr1', 'dr2', 'dr3', 'dr4', 'dr5', 'dr6', 'dr7', 'dr8', 'dr9', 'dra', 'drb', 'drc', 'drd', 'dre', 'drf',
])
const ARMOR_CODES_AND_TAGS = new Set([
'tors', 'torso', 'body', 'cloa', 'armo', 'armor',
'qui', 'lea', 'hla', 'stu', 'rng', 'scl', 'chn', 'brs', 'spl', 'plt', 'fld', 'gth', 'ful', 'aar', 'ltp',
'xui', 'xea', 'xla', 'xtu', 'xng', 'xcl', 'xhn', 'xrs', 'xpl', 'xlt', 'xld', 'xth', 'xul', 'xar', 'xtp',
'uui', 'uea', 'ula', 'utu', 'ung', 'ucl', 'uhn', 'urs', 'upl', 'ult', 'uld', 'uth', 'uul', 'uar', 'utp',
])
const WEAPON_CODES_AND_TAGS = new Set([
'weap', 'weapon', 'mele', 'bow', 'miss', 'rod', 'swor', 'sword',
'axe', 'mace', 'hamm', 'club', 'pole', 'spea', 'staf', 'wand', 'scep',
'h2h', 'h2h2', 'orb', 'knif', 'dagg', 'jave', 'tkni', 'taxe', 'tpot',
'xbow', 'abow', 'aspe', 'ajav', 'blun', 'comb',
])
const NON_SOCKETABLE_EQUIP_SLOTS = new Set([
'gloves', 'boots', 'belt', 'ring1', 'ring2', 'amulet',
])
/**
* Resolves the 1.13c socket target category ('weapon' | 'helm' | 'armor' | 'shield')
* for a domain `Item` or UI `UiInventoryItem`, or returns `null` if the item cannot accept sockets.
*/
export function resolveSocketTargetCategory(item: {
readonly code?: string | undefined
readonly type?: string | undefined
readonly allowedSlots?: readonly string[] | undefined
readonly damage?: string | number | undefined
readonly defense?: number | undefined
readonly base?: {
readonly id?: string | undefined
readonly kind?: string | undefined
readonly tags?: readonly string[] | undefined
} | undefined
}): SocketTargetCategory | null {
// 1. Check explicit UI allowedSlots if present
if (item.allowedSlots && item.allowedSlots.length > 0) {
const slots = item.allowedSlots
if (slots.includes('helm')) return 'helm'
if (slots.includes('armor')) return 'armor'
if (slots.includes('weapon1')) return 'weapon'
if (slots.includes('weapon2')) {
const c = (item.code ?? item.base?.id ?? '').trim().toLowerCase()
const t = (item.type ?? '').trim().toLowerCase()
if (SHIELD_CODES_AND_TAGS.has(c) || SHIELD_CODES_AND_TAGS.has(t)) {
return 'shield'
}
if (WEAPON_CODES_AND_TAGS.has(c) || WEAPON_CODES_AND_TAGS.has(t)) {
return 'weapon'
}
// If it has weapon damage string/number and no defense, it is an off-hand weapon; otherwise shield/shrunken head
if (item.damage !== undefined && item.damage !== 0 && item.damage !== '' && !item.defense) {
return 'weapon'
}
return 'shield'
}
if (slots.every((s) => NON_SOCKETABLE_EQUIP_SLOTS.has(s))) {
return null
}
}
// 2. Check domain Item type / tags / code / base.kind
const candidates: string[] = []
if (item.type) candidates.push(item.type.trim().toLowerCase())
if (item.base?.tags) {
for (const tag of item.base.tags) {
candidates.push(tag.trim().toLowerCase())
}
}
const itemCode = (item.code ?? item.base?.id ?? '').trim().toLowerCase()
if (itemCode) candidates.push(itemCode)
for (const token of candidates) {
if (SHIELD_CODES_AND_TAGS.has(token)) return 'shield'
if (HELM_CODES_AND_TAGS.has(token)) return 'helm'
if (WEAPON_CODES_AND_TAGS.has(token)) return 'weapon'
}
for (const token of candidates) {
if (ARMOR_CODES_AND_TAGS.has(token)) return 'armor'
}
if (item.base?.kind === 'weapon') return 'weapon'
if (typeof item.damage === 'string' && item.damage.trim().length > 0) return 'weapon'
if (typeof item.damage === 'number' && item.damage > 0) return 'weapon'
if (item.base?.kind === 'armor') return 'armor'
return null
}
/**
* Checks whether a domain `Item` is a valid socketable filler (Gem, Rune, or Jewel).
*/
export function isSocketableDomainItem(item: Item | undefined | null): boolean {
if (!item) return false
const code = (item.code ?? item.base?.id ?? '').trim().toLowerCase()
const type = ((item as { type?: string }).type ?? (item.base as { type?: string } | undefined)?.type ?? '').trim().toLowerCase()
if (isGemCode(code) || isRuneCode(code) || isJewelCode(code) || isJewelCode(type)) {
return true
}
if (item.rarity === 'rune' || String(item.quality).toLowerCase() === 'rune') {
return true
}
if (item.base?.tags?.some((t) => t.toLowerCase() === 'jewl' || t.toLowerCase() === 'gem')) {
return true
}
return false
}
/**
* Checks whether `socketable` can be inserted into `host` per Diablo II 1.13c rules:
* - `socketable` must be a Gem, Rune, or Jewel.
* - `host` must NOT itself be a socketable filler.
* - `host` must be identified (`identified !== false`).
* - `host` must have `totalSockets > 0` and at least 1 open socket.
* - `host` must belong to a valid socketable equipment category ('weapon' | 'helm' | 'armor' | 'shield').
*/
export function canSocketDomainItem(socketable: Item | undefined | null, host: Item | undefined | null): boolean {
if (!socketable || !host) return false
if (!isSocketableDomainItem(socketable)) return false
if (isSocketableDomainItem(host)) return false
if (host.identified === false) return false
const totalSockets = host.totalSockets ?? (host as { sockets?: number }).sockets ?? 0
if (totalSockets <= 0) return false
const currentSocketed = host.socketedItems?.length ?? host.socketedCount ?? 0
if (currentSocketed >= totalSockets) return false
const category = resolveSocketTargetCategory(host)
return category !== null
}
/**
* Resolves the structured `RolledItemProp[]` modifiers contributed by a single socketed item
* when placed into a host item of `hostCategory`.
*
* - **Jewels (`jew` / `jewl`)**: Position-independent! Returns the jewel's own `rolledProps`
* (or `stats` converted to `RolledItemProp[]`) identically across all equipment categories.
* - **Gems & Runes**: Position-dependent! Dispatches `weaponMods` for `'weapon'`,
* `helmMods` for `'helm'` and `'armor'`, and `shieldMods` for `'shield'`.
*/
export function getSocketedItemModifiersForHost(
socketable: Item,
hostCategory: SocketTargetCategory,
gemTable: GemTable = CANONICAL_GEMS_TABLE,
): RolledItemProp[] {
const code = (socketable.code ?? socketable.base?.id ?? '').trim().toLowerCase()
const type = ((socketable as { type?: string }).type ?? (socketable.base as { type?: string } | undefined)?.type ?? '').trim().toLowerCase()
// 1. Jewel: slot-independent modifiers
if (isJewelCode(code) || isJewelCode(type) || socketable.base?.tags?.includes('jewl')) {
if (socketable.rolledProps && socketable.rolledProps.length > 0) {
return socketable.rolledProps.map((p) => ({ ...p }))
}
const propsFromStats: RolledItemProp[] = []
for (const [statKey, val] of Object.entries(socketable.stats ?? {})) {
if (typeof val === 'number' && val !== 0) {
propsFromStats.push({
code: statKey,
value: val,
min: val,
max: val,
})
}
}
return propsFromStats
}
// 2. Gem or Rune: slot-dependent modifiers from Gems.txt
const gem = gemTable.get(code)
if (!gem) return []
let mods: readonly GemModifier[]
switch (hostCategory) {
case 'weapon':
mods = gem.weaponMods
break
case 'helm':
case 'armor':
mods = gem.helmMods
break
case 'shield':
mods = gem.shieldMods
break
}
return mods.map((m) => ({
code: m.code,
...(m.param !== undefined ? { param: m.param } : {}),
value: m.min,
min: m.min,
max: m.max,
}))
}
function defaultRunewordIsA(child: string, parent: string): boolean {
const c = child.trim().toLowerCase()
const p = parent.trim().toLowerCase()
if (!c || !p) return false
if (c === p) return true
if (p === 'shld' || p === 'shie') return SHIELD_CODES_AND_TAGS.has(c)
if (p === 'helm') return HELM_CODES_AND_TAGS.has(c)
if (p === 'tors' || p === 'armo') return ARMOR_CODES_AND_TAGS.has(c)
if (p === 'weap') return WEAPON_CODES_AND_TAGS.has(c)
if (p === 'mele') {
return WEAPON_CODES_AND_TAGS.has(c) && !['bow', 'xbow', 'abow', 'miss', 'orb', 'tpot'].includes(c)
}
if (p === 'miss') {
return ['bow', 'xbow', 'abow', 'miss'].includes(c)
}
return false
}
/**
* Inserts a socketable `Item` (Gem, Rune, or Jewel) into a socketed `host` `Item`
* following Diablo II 1.13c rules (`ITEMS_SocketItem`).
*
* Throws an error if `canSocketDomainItem(socketable, host)` is false.
*/
export function socketItemIntoHost(
host: Item,
socketable: Item,
gemTable: GemTable = CANONICAL_GEMS_TABLE,
): Item {
if (!canSocketDomainItem(socketable, host)) {
throw new Error(
`Cannot socket item "${socketable?.name ?? socketable?.code}" into "${host?.name ?? host?.code}"`,
)
}
const category = resolveSocketTargetCategory(host)!
const totalSockets = host.totalSockets ?? (host as { sockets?: number }).sockets ?? 0
const insertedCopy: Item = {
...socketable,
stack: 1,
}
const newSocketedItems: Item[] = [...(host.socketedItems ?? []), insertedCopy]
const newSocketedCount = newSocketedItems.length
// Compute modifiers from the newly socketed item
const addedProps = getSocketedItemModifiersForHost(insertedCopy, category, gemTable)
const newRolledProps: RolledItemProp[] = [...(host.rolledProps ?? []), ...addedProps]
const newStats: Record<string, number> = { ...(host.stats ?? {}) }
for (const p of addedProps) {
newStats[p.code] = (newStats[p.code] ?? 0) + p.value
}
// Update required level: max(host.reqLevel, socketable.reqLevel)
const sockCode = (insertedCopy.code ?? insertedCopy.base?.id ?? '').trim().toLowerCase()
const canonSocketInfo = getSocketableInfo(sockCode)
const socketReqLevel =
canonSocketInfo !== undefined
? canonSocketInfo.reqLevel
: ((insertedCopy as { reqLevel?: number }).reqLevel ?? insertedCopy.level ?? 0)
const hostReqLevel = (host as { reqLevel?: number }).reqLevel ?? 0
const newReqLevel = Math.max(hostReqLevel, socketReqLevel)
// Concatenate rune letters if runes are socketed
const runeLetters: string[] = []
const allAreRunes = newSocketedItems.every((s) => {
const c = (s.code ?? s.base?.id ?? '').trim().toLowerCase()
const rInfo = RUNE_INFO[c]
if (rInfo) {
runeLetters.push(rInfo.letter)
return true
}
return false
})
const newSocketedRunes =
runeLetters.length > 0 ? runeLetters.join('') : (host as { runewordRunes?: string }).runewordRunes
let updatedHost: Item = {
...host,
totalSockets,
socketedItems: newSocketedItems,
socketedCount: newSocketedCount,
rolledProps: newRolledProps,
stats: newStats,
_socketPropsMerged: true,
...(newReqLevel > 0 ? { reqLevel: newReqLevel } : {}),
...(newSocketedRunes ? { runewordRunes: newSocketedRunes } : {}),
} as Item
// Check for Runeword activation when all sockets of a normal/superior/low base item are filled with runes
if (allAreRunes && newSocketedCount === totalSockets && isNormalQuality(host)) {
const runeCodes = newSocketedItems.map((s) => (s.code ?? s.base?.id ?? '').trim().toLowerCase())
const rwMatch = checkRunewordMatch(updatedHost, runeCodes, defaultRunewordIsA, undefined, {
isLadder: true,
})
if (rwMatch) {
const rwProps: RolledItemProp[] = rwMatch.t1Codes.map((m) => ({
code: m.code,
...(m.param !== undefined ? { param: m.param } : {}),
value: m.min,
min: m.min,
max: m.max,
}))
const mergedProps = [...newRolledProps, ...rwProps]
const mergedStats = { ...newStats }
for (const p of rwProps) {
mergedStats[p.code] = (mergedStats[p.code] ?? 0) + p.value
}
const rwName = rwMatch.runeName || rwMatch.name
updatedHost = {
...updatedHost,
name: rwName,
rarity: 'runeword',
runeword: {
id: rwMatch.runewordId,
name: rwName,
},
rolledProps: mergedProps,
stats: mergedStats,
} as Item
}
}
return updatedHost
}
/**
* Calculates maximum allowed sockets for an item based on base gemsockets,
* item type socket tier limits, and item level (ilvl).
@ -447,3 +900,4 @@ export function getMaxSockets(
return Math.max(0, Math.min(baseGemsockets, typeMax))
}

View File

@ -52,11 +52,24 @@ import {
GEM_INFO,
RUNE_INFO,
getSocketableInfo,
getSocketableSlotText,
getSocketableSlotLines,
isGemCode,
isRuneCode,
isJewelCode,
isSocketableCode,
type GemInfo,
type RuneInfo,
type SocketTargetCategory,
} from './gems-data.ts'
import {
CANONICAL_GEMS_TABLE,
resolveSocketTargetCategory,
getSocketedItemModifiersForHost,
isSocketableDomainItem,
canSocketDomainItem,
socketItemIntoHost,
} from './gems.ts'
export {
getTooltipLocale,
@ -77,11 +90,24 @@ export {
GEM_INFO,
RUNE_INFO,
getSocketableInfo,
getSocketableSlotText,
getSocketableSlotLines,
isGemCode,
isRuneCode,
isJewelCode,
isSocketableCode,
type GemInfo,
type RuneInfo,
type SocketTargetCategory,
} from './gems-data.ts'
export {
CANONICAL_GEMS_TABLE,
resolveSocketTargetCategory,
getSocketedItemModifiersForHost,
isSocketableDomainItem,
canSocketDomainItem,
socketItemIntoHost,
} from './gems.ts'
export interface FormattedStatLine {
text: string
@ -2229,6 +2255,53 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
}
// 0. If this item is an un-socketed Jewel ('jew' / 'jewl'), prepend the 1.13c socketable instruction
const isJewel =
isJewelCode(itemCode) ||
isJewelCode((item as { type?: string }).type) ||
Boolean(base.tags?.some((t) => t.toLowerCase() === 'jewl'))
if (isJewel) {
const isZh = locale === 'zh' || locale === 'bilingual'
lines.push({
text: isZh ? '可镶嵌进有凹槽的装备' : 'Can be Inserted into Socketed Items',
color: 'instruction',
})
}
// Respect explicit reqLevel on the item if present
if (typeof (item as any).reqLevel === 'number' && (item as any).reqLevel > 0) {
affixReqLevel = Math.max(affixReqLevel, (item as any).reqLevel)
}
// Aggregate modifiers, reqLevel, and rune letters from socketedItems (Gems, Runes, Jewels)
let derivedRunewordRunes: string | undefined = (item as any).runewordRunes
if (item.socketedItems && item.socketedItems.length > 0) {
const hostCategory = resolveSocketTargetCategory(item)
const runeLetters: string[] = []
for (const sockItem of item.socketedItems) {
const sockCode = (sockItem.code || (sockItem.base as any)?.code || sockItem.base?.id || '').trim().toLowerCase()
const sockInfo = getSocketableInfo(sockCode)
const sockReqLvl =
sockInfo !== undefined
? sockInfo.reqLevel
: ((sockItem as any).reqLevel ?? sockItem.level ?? 0)
if (sockReqLvl > 0) {
affixReqLevel = Math.max(affixReqLevel, sockReqLvl)
}
const rInfo = RUNE_INFO[sockCode]
if (rInfo) {
runeLetters.push(rInfo.letter)
}
if (!(item as any)._socketPropsMerged && hostCategory) {
const sockProps = getSocketedItemModifiersForHost(sockItem, hostCategory, CANONICAL_GEMS_TABLE)
rawProps.push(...sockProps)
}
}
if (!derivedRunewordRunes && runeLetters.length > 0) {
derivedRunewordRunes = runeLetters.join('')
}
}
// Merge and sort properties per Issue #125 (min/max damage pairs, dgrp resists/attributes, descpriority)
const mergedProps = aggregateAndSortProperties(rawProps)
for (const prop of mergedProps) {
@ -2439,7 +2512,8 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
durability,
sockets,
lines,
...((item as any).runewordRunes ? { runewordRunes: (item as any).runewordRunes } : {}),
...(isJewel ? { isSocketable: true } : {}),
...(derivedRunewordRunes ? { runewordRunes: derivedRunewordRunes } : {}),
...((item as any).speedText ? { speedText: (item as any).speedText } : {}),
}
}

View File

@ -286,6 +286,8 @@ export interface RolledItemProp {
export interface ItemRunewordData {
/** Runeword ID (12 bits) from Runes.txt. */
readonly id: number
/** Runeword display/reference name (e.g. "Ancient's Pledge", "Stealth"). */
readonly name?: string | undefined
/** Runeword parameter / property index (4 bits). */
readonly param?: number | undefined
}

View File

@ -15,6 +15,27 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts'
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
import type { Item } from '../game/items.ts'
import type { CharacterClassCode } from '../game/classes.ts'
import {
getGemColorTransform,
getSocketableInfo,
getSocketableSlotLines,
isGemCode,
isJewelCode,
isRuneCode,
isSocketableCode,
type SocketTargetCategory,
} from '../game/gems-data.ts'
import {
getSocketedItemModifiersForHost,
isSocketableDomainItem,
resolveSocketTargetCategory,
socketItemIntoHost,
} from '../game/gems.ts'
import { CANONICAL_RUNES_TABLE, checkRunewordMatch, RUNE_CODE_TO_NAME } from '../game/runeword.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { isA } from '../game/item-types.ts'
import { formatPropertyCode } from '../game/item-tooltip.ts'
import { translateItemName } from '../game/tooltip-i18n.ts'
import {
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
@ -28,7 +49,7 @@ import {
resolveItemInvFile,
} from './item-bridge.ts'
export type { SpriteRect }
export type { SpriteRect, SocketTargetCategory }
export {
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
@ -87,6 +108,7 @@ export interface UiInventoryItem {
readonly speedTextEn?: string | undefined
readonly runewordRunes?: string | undefined
readonly sockets?: number | undefined
readonly socketedItems?: readonly UiInventoryItem[] | undefined
readonly ethereal?: boolean | undefined
readonly setPieces?: readonly string[] | undefined
readonly setPiecesEn?: readonly string[] | undefined
@ -1403,6 +1425,649 @@ export interface IdentifyCursorSource {
readonly code: 'ibk' | 'isc'
}
/**
* Ground Truth (Diablo II v1.13c):
* Returns true if `item` is a socketable filler (Gem, Rune, or Jewel).
*/
export function isSocketableUiItem(item: UiInventoryItem | null | undefined): boolean {
if (!item) return false
const code = (item.code ?? '').trim().toLowerCase()
if (isSocketableCode(code)) return true
if (
item.quality === 'rune' &&
(item.sockets ?? 0) === 0 &&
item.allowedSlots.length === 0 &&
!item.runewordRunes
) {
return true
}
if (item.rawItem && isSocketableDomainItem(item.rawItem)) {
return true
}
const baseZh = (item.baseNameZh ?? '').trim()
const baseEn = (item.baseNameEn ?? '').trim().toLowerCase()
if (baseZh.includes('珠宝') || baseEn === 'jewel') {
return true
}
return false
}
/**
* Ground Truth (D2Common.dll 1.13c @ 0x6fd75a90):
* Resolves the target equipment slot category (`'weapon' | 'helm' | 'armor' | 'shield'`)
* for a socketed host `UiInventoryItem`, or `null` if it cannot accept sockets.
*/
export function resolveUiSocketTargetCategory(
target: UiInventoryItem | null | undefined,
): SocketTargetCategory | null {
if (!target) return null
if (isSocketableUiItem(target)) return null
const slots = target.allowedSlots ?? []
if (slots.includes('helm')) return 'helm'
if (slots.includes('armor')) return 'armor'
if (slots.includes('weapon1')) return 'weapon'
if (slots.includes('weapon2')) {
if (target.damage !== undefined && target.defense === undefined) {
return 'weapon'
}
const fromDomain = resolveSocketTargetCategory({
code: target.code,
base: target.rawItem?.base,
allowedSlots: target.allowedSlots,
})
return fromDomain ?? 'shield'
}
return resolveSocketTargetCategory({
code: target.code,
base: target.rawItem?.base,
allowedSlots: target.allowedSlots,
})
}
/**
* Ground Truth (D2Common.dll 1.13c & D2Game.dll `0x6fc479c0`):
* Checks whether `socketable` (Gem, Rune, or Jewel) can be inserted into `target`:
* - `socketable` must be a valid identified socketable item (Gem, Rune, or Jewel).
* - `target` must be an identified non-socketable equipment item in `weapon`, `helm`, `armor`, or `shield` category.
* - `target` must have `sockets > 0` and `(socketedItems?.length ?? 0) < sockets`.
*/
export function canSocketUiItem(
socketable: UiInventoryItem | null | undefined,
target: UiInventoryItem | null | undefined,
): boolean {
if (!socketable || !target || socketable === target) return false
if (!isSocketableUiItem(socketable)) return false
if (isSocketableUiItem(target)) return false
if (isUiItemUnidentified(socketable) || isUiItemUnidentified(target)) return false
const totalSockets =
target.sockets ??
target.rawItem?.totalSockets ??
(target.rawItem as { sockets?: number } | undefined)?.sockets ??
0
if (totalSockets <= 0) return false
const usedSockets = target.socketedItems?.length ?? target.rawItem?.socketedItems?.length ?? 0
if (usedSockets >= totalSockets) return false
const category = resolveUiSocketTargetCategory(target)
return category !== null
}
function isSocketInstructionOrCountLine(text: string): boolean {
const t = text.trim()
return (
t.includes('可镶嵌进有凹槽的装备') ||
t.includes('Can be Inserted into Socketed Items') ||
/^带插槽\s*\(\d+\)$/.test(t) ||
/^凹槽\s*\(\d+\)$/.test(t) ||
/^Socketed\s*\(\d+\)$/i.test(t) ||
t === '未辨识' ||
t === '未鉴定' ||
t === 'Unidentified'
)
}
function insertStatLinesBeforeSocketLine(
existing: readonly { text: string; color?: D2ColorCode }[],
added: readonly { text: string; color?: D2ColorCode }[],
): { text: string; color?: D2ColorCode }[] {
if (added.length === 0) return [...existing]
const sockIdx = existing.findIndex(s =>
/^(?:带插槽|凹槽|Socketed)\s*\(\d+\)$/i.test(s.text.trim()),
)
if (sockIdx === -1) {
return [...existing, ...added]
}
return [...existing.slice(0, sockIdx), ...added, ...existing.slice(sockIdx)]
}
function inferCandidateRunewordItemTypes(
target: UiInventoryItem,
category: SocketTargetCategory,
): string[] {
const types = new Set<string>()
const code = (target.code ?? '').trim().toLowerCase()
const dropTables = getEmbeddedDropTables()
const rawBaseType = (target.rawItem?.base as { type?: string } | undefined)?.type
if (rawBaseType) {
types.add(rawBaseType.trim().toLowerCase())
}
if (target.rawItem?.base?.tags) {
for (const tag of target.rawItem.base.tags) {
types.add(tag.trim().toLowerCase())
}
}
const wRow = dropTables.weapons.get(code) as { type?: string } | undefined
if (wRow?.type) types.add(wRow.type.trim().toLowerCase())
const aRow = dropTables.armor.get(code) as { type?: string } | undefined
if (aRow?.type) types.add(aRow.type.trim().toLowerCase())
if (category === 'helm') {
types.add('helm')
types.add('armo')
} else if (category === 'armor') {
types.add('tors')
types.add('armo')
} else if (category === 'shield') {
types.add('shld')
types.add('shie')
types.add('armo')
const nameStr = `${target.name} ${target.baseNameZh} ${target.baseNameEn ?? ''}`.toLowerCase()
if (nameStr.includes('targe') || nameStr.includes('rondache') || nameStr.includes('akaran') || nameStr.includes('vortex') || nameStr.includes('kurast') || nameStr.includes('zakarum') || target.reqClass === 'pal') {
types.add('pash')
}
} else if (category === 'weapon') {
const nameStr = `${target.name} ${target.baseNameZh} ${target.baseNameEn ?? ''} ${target.speedText ?? ''}`.toLowerCase()
if (nameStr.includes('sword') || nameStr.includes('blade') || nameStr.includes('剑') || nameStr.includes('刃')) {
types.add('swor')
types.add('mele')
}
if (nameStr.includes('axe') || nameStr.includes('斧')) {
types.add('axe')
types.add('mele')
}
if (nameStr.includes('pole') || nameStr.includes('halberd') || nameStr.includes('scythe') || nameStr.includes('thresher') || nameStr.includes('长柄') || nameStr.includes('镰')) {
types.add('pole')
types.add('mele')
}
if (nameStr.includes('staff') || nameStr.includes('stave') || nameStr.includes('法杖') || nameStr.includes('长杖')) {
types.add('staf')
types.add('mele')
}
if (nameStr.includes('mace') || nameStr.includes('flail') || nameStr.includes('连枷') || nameStr.includes('钉头槌') || nameStr.includes('锤')) {
types.add('mace')
types.add('hamm')
types.add('club')
types.add('mele')
}
if (nameStr.includes('scepter') || nameStr.includes('权杖')) {
types.add('scep')
types.add('mele')
}
if (nameStr.includes('wand') || nameStr.includes('魔杖')) {
types.add('wand')
types.add('mele')
}
if (nameStr.includes('bow') || nameStr.includes('弓')) {
types.add('bow')
types.add('miss')
}
if (nameStr.includes('claw') || nameStr.includes('katar') || nameStr.includes('爪') || nameStr.includes('拳刃')) {
types.add('h2h')
types.add('mele')
}
if (nameStr.includes('spear') || nameStr.includes('lance') || nameStr.includes('长矛') || nameStr.includes('枪')) {
types.add('spea')
types.add('mele')
}
types.add('weap')
if (!types.has('miss')) {
types.add('mele')
}
}
return Array.from(types).filter(Boolean)
}
function applyStatDeltaToTargetBase(
target: UiInventoryItem,
addedZh: readonly { text: string; color?: D2ColorCode }[],
addedEn: readonly { text: string; color?: D2ColorCode }[],
): void {
let edPct = 0
let defPct = 0
let flatDef = 0
let addMinDmg = 0
let addMaxDmg = 0
let reqPct = 0
let indestruct = false
for (const line of addedZh) {
const t = line.text.trim()
if (t.includes('无法破坏') || /Indestructible/i.test(t)) {
indestruct = true
}
const edMatch = /^\+(\d+)%\s*(?:增强伤害|强化伤害)/.exec(t)
if (edMatch) edPct += Number(edMatch[1])
const defPctMatch = /^\+(\d+)%\s*(?:增强防御|强化防御)/.exec(t)
if (defPctMatch) defPct += Number(defPctMatch[1])
const flatDefMatch = /^\+(\d+)\s*防御$/.exec(t)
if (flatDefMatch) flatDef += Number(flatDefMatch[1])
const physRangeMatch = /^增加\s*(\d+)\s*-\s*(\d+)\s*伤害$/.exec(t)
if (physRangeMatch) {
addMinDmg += Number(physRangeMatch[1])
addMaxDmg += Number(physRangeMatch[2])
}
const minDmgMatch = /^\+(\d+)\s*最小伤害$/.exec(t)
if (minDmgMatch) addMinDmg += Number(minDmgMatch[1])
const maxDmgMatch = /^\+(\d+)\s*最大伤害$/.exec(t)
if (maxDmgMatch) addMaxDmg += Number(maxDmgMatch[1])
const reqMatch = /^(?:装备)?需求\s*(-\d+)%/.exec(t)
if (reqMatch) reqPct += Number(reqMatch[1])
}
// Fallback check on English lines if a test only supplied English stat text on a custom jewel
if (
edPct === 0 &&
defPct === 0 &&
flatDef === 0 &&
addMinDmg === 0 &&
addMaxDmg === 0 &&
reqPct === 0 &&
!indestruct
) {
for (const line of addedEn) {
const t = line.text.trim()
if (/Indestructible/i.test(t)) indestruct = true
const edMatch = /^\+(\d+)%\s*Enhanced Damage/i.exec(t)
if (edMatch) edPct += Number(edMatch[1])
const defPctMatch = /^\+(\d+)%\s*Enhanced Defense/i.exec(t)
if (defPctMatch) defPct += Number(defPctMatch[1])
const flatDefMatch = /^\+(\d+)\s*Defense$/i.exec(t)
if (flatDefMatch) flatDef += Number(flatDefMatch[1])
const physRangeMatch = /^Adds\s*(\d+)\s*(?:-|to)\s*(\d+)\s*Damage$/i.exec(t)
if (physRangeMatch) {
addMinDmg += Number(physRangeMatch[1])
addMaxDmg += Number(physRangeMatch[2])
}
const minDmgMatch = /^\+(\d+)\s*to Minimum Damage$/i.exec(t)
if (minDmgMatch) addMinDmg += Number(minDmgMatch[1])
const maxDmgMatch = /^\+(\d+)\s*to Maximum Damage$/i.exec(t)
if (maxDmgMatch) addMaxDmg += Number(maxDmgMatch[1])
const reqMatch = /^Requirements\s*(-\d+)%/i.exec(t)
if (reqMatch) reqPct += Number(reqMatch[1])
}
}
const mutable = target as unknown as Record<string, unknown>
if (indestruct) {
delete mutable.durability
}
if (reqPct !== 0) {
if (mutable._baseReqStr === undefined && target.reqStr !== undefined) {
mutable._baseReqStr = target.reqStr
}
if (mutable._baseReqDex === undefined && target.reqDex !== undefined) {
mutable._baseReqDex = target.reqDex
}
const nextCumReq = ((mutable._cumReqPct as number | undefined) ?? 0) + reqPct
mutable._cumReqPct = nextCumReq
if (typeof mutable._baseReqStr === 'number') {
mutable.reqStr = Math.max(0, Math.floor((mutable._baseReqStr * (100 + nextCumReq)) / 100))
}
if (typeof mutable._baseReqDex === 'number') {
mutable.reqDex = Math.max(0, Math.floor((mutable._baseReqDex * (100 + nextCumReq)) / 100))
}
}
if (target.defense !== undefined && (defPct !== 0 || flatDef !== 0)) {
if (mutable._baseDefense === undefined) {
mutable._baseDefense = target.defense
}
const nextCumDefPct = ((mutable._cumDefPct as number | undefined) ?? 0) + defPct
const nextCumFlatDef = ((mutable._cumFlatDef as number | undefined) ?? 0) + flatDef
mutable._cumDefPct = nextCumDefPct
mutable._cumFlatDef = nextCumFlatDef
const baseDef = mutable._baseDefense as number
mutable.defense = Math.floor((baseDef * (100 + nextCumDefPct)) / 100) + nextCumFlatDef
}
if (target.damage !== undefined && (edPct !== 0 || addMinDmg !== 0 || addMaxDmg !== 0)) {
if (mutable._baseDamageStr === undefined) {
mutable._baseDamageStr = target.damage
}
if (mutable._baseDamageEnStr === undefined && target.damageEn !== undefined) {
mutable._baseDamageEnStr = target.damageEn
}
const nextCumEdPct = ((mutable._cumEdPct as number | undefined) ?? 0) + edPct
const nextCumMinDmg = ((mutable._cumMinDmg as number | undefined) ?? 0) + addMinDmg
const nextCumMaxDmg = ((mutable._cumMaxDmg as number | undefined) ?? 0) + addMaxDmg
mutable._cumEdPct = nextCumEdPct
mutable._cumMinDmg = nextCumMinDmg
mutable._cumMaxDmg = nextCumMaxDmg
const baseDmgStr = String(mutable._baseDamageStr)
mutable.damage = baseDmgStr.replace(/(\d+)\s*-\s*(\d+)/g, (_m, minS, maxS) => {
const bMin = Number(minS)
const bMax = Number(maxS)
const nMin = Math.floor((bMin * (100 + nextCumEdPct)) / 100) + nextCumMinDmg
const nMax = Math.floor((bMax * (100 + nextCumEdPct)) / 100) + nextCumMaxDmg
return `${nMin} - ${nMax}`
})
if (typeof mutable._baseDamageEnStr === 'string') {
mutable.damageEn = mutable._baseDamageEnStr.replace(
/(\d+)\s*(?:to|-)\s*(\d+)/gi,
(_m, minS, maxS) => {
const bMin = Number(minS)
const bMax = Number(maxS)
const nMin = Math.floor((bMin * (100 + nextCumEdPct)) / 100) + nextCumMinDmg
const nMax = Math.floor((bMax * (100 + nextCumEdPct)) / 100) + nextCumMaxDmg
return `${nMin} to ${nMax}`
},
)
}
}
}
/**
* Ground Truth (D2Game.dll 1.13c @ 0x6fc479c0 & D2Common.dll @ 0x6fd75a90):
* Inserts 1 unit of `socketable` (Gem, Rune, or Jewel) into `target`:
* 1. Jewels (`jew` / `jewl`) apply their own modifiers identically regardless of `target`'s equipment slot.
* 2. Gems (`gcv`..`skz`) and Runes (`r01`..`r33`) apply slot-dependent modifiers (`weapon`, `helm`, `armor`, `shield`) per `Gems.txt`.
* 3. Updates `target.socketedItems`, `target.stats`, `target.statsEn`, `target.reqLevel`, `target.defense`, `target.damage`, `target.reqStr`, `target.reqDex`, and `target.runewordRunes`.
* 4. Checks and activates Runeword completion (`checkRunewordMatch`) when all sockets of a normal-quality base item are filled with runes.
*/
export function applySocketToUiItem(
target: UiInventoryItem,
socketable: UiInventoryItem,
): UiInventoryItem {
if (!canSocketUiItem(socketable, target)) {
throw new Error(
`Cannot socket item "${socketable?.code ?? 'unknown'}" into target "${target?.code ?? 'unknown'}"`,
)
}
const category = resolveUiSocketTargetCategory(target)!
const code = (socketable.code ?? '').trim().toLowerCase()
const insertedUnit: UiInventoryItem = {
...socketable,
quantity: 1,
stats: [...socketable.stats],
...(socketable.statsEn ? { statsEn: [...socketable.statsEn] } : {}),
}
const nextSocketed = [...(target.socketedItems ?? []), insertedUnit]
const mutable = target as unknown as Record<string, unknown>
mutable.socketedItems = nextSocketed
if (target.quality === 'normal' && !target.invtransform && !target.chrtransform) {
const gemTransform = getGemColorTransform(code)
if (gemTransform) {
mutable.invtransform = gemTransform
mutable.chrtransform = gemTransform
}
}
let addedZh: { text: string; color?: D2ColorCode }[] = []
let addedEn: { text: string; color?: D2ColorCode }[] = []
const isJewel =
isJewelCode(code) ||
Boolean(socketable.baseNameZh?.includes('珠宝')) ||
socketable.baseNameEn?.trim().toLowerCase() === 'jewel'
if (isJewel) {
// 1. Jewel: slot-independent modifiers across weapon, helm, armor, and shield
addedZh = socketable.stats
.filter(s => !isSocketInstructionOrCountLine(s.text))
.map(s => ({ text: s.text, color: (s.color ?? 'blue') as D2ColorCode }))
const srcEn = socketable.statsEn ?? socketable.stats
addedEn = srcEn
.filter(s => !isSocketInstructionOrCountLine(s.text))
.map(s => ({ text: s.text, color: (s.color ?? 'blue') as D2ColorCode }))
} else if (isGemCode(code) || isRuneCode(code)) {
// 2. Gem or Rune: slot-dependent modifiers based on target category ('weapon' | 'helm' | 'armor' | 'shield')
addedZh = getSocketableSlotLines(code, category, 'zh').map(text => ({
text,
color: 'blue' as D2ColorCode,
}))
addedEn = getSocketableSlotLines(code, category, 'en').map(text => ({
text,
color: 'blue' as D2ColorCode,
}))
} else if (socketable.rawItem) {
const mods = getSocketedItemModifiersForHost(socketable.rawItem, category)
addedZh = mods
.map(m => ({
text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param, 'zh'),
color: 'blue' as D2ColorCode,
}))
.filter(l => l.text.length > 0)
addedEn = mods
.map(m => ({
text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param, 'en'),
color: 'blue' as D2ColorCode,
}))
.filter(l => l.text.length > 0)
}
// Update Required Level = max(host.reqLevel, socketable.reqLevel)
const sockInfo = getSocketableInfo(code)
const sockReqLvl = Math.max(sockInfo?.reqLevel ?? 0, socketable.reqLevel ?? 0)
const nextReqLevel = Math.max(target.reqLevel ?? 0, sockReqLvl)
if (nextReqLevel > 0) {
mutable.reqLevel = nextReqLevel
}
// Update Runeword Rune sequence display (e.g. 'RalOrtTal' or 'TalThulOrtAmn')
const runeSequence = nextSocketed
.filter(s => isRuneCode(s.code))
.map(s => RUNE_CODE_TO_NAME[(s.code ?? '').trim().toLowerCase()] ?? s.name)
.join('')
if (runeSequence.length > 0 && (target.quality === 'normal' || target.quality === 'rune')) {
mutable.runewordRunes = `'${runeSequence}'`
}
// Check Runeword completion if all sockets of a normal item are now filled with Runes
const totalSockets =
target.sockets ??
target.rawItem?.totalSockets ??
(target.rawItem as { sockets?: number } | undefined)?.sockets ??
0
if (
totalSockets > 0 &&
nextSocketed.length === totalSockets &&
target.quality === 'normal' &&
nextSocketed.every(s => isRuneCode(s.code))
) {
const dropTables = getEmbeddedDropTables()
const runeCodes = nextSocketed.map(s => (s.code ?? '').trim().toLowerCase())
const candidateTypes = inferCandidateRunewordItemTypes(target, category)
const isAPred = (t: string, targetType: string): boolean => {
if (t === targetType) return true
try {
if (dropTables.isA(t, targetType)) return true
} catch {
// ignore
}
try {
if (isA(t, targetType, dropTables.itemTypes)) return true
} catch {
// ignore
}
return false
}
const syntheticBaseItem = {
code: target.code ?? '',
quality: 'normal',
totalSockets,
prefix: null,
suffix: null,
base: {
id: target.code ?? '',
name: target.baseNameEn ?? target.name,
kind: category === 'weapon' ? 'weapon' : 'armor',
tags: candidateTypes,
},
} as unknown as Item
const matchedRw = checkRunewordMatch(
syntheticBaseItem,
runeCodes,
isAPred,
CANONICAL_RUNES_TABLE,
{ isLadder: true },
)
if (matchedRw) {
const rwNameEn = matchedRw.runeName || matchedRw.name
mutable.quality = 'rune'
mutable.name = rwNameEn
const rwZh = translateItemName(rwNameEn)
mutable.nameZh = rwZh && rwZh !== rwNameEn ? rwZh : rwNameEn
mutable.runewordRunes = `'${runeCodes.map(rc => RUNE_CODE_TO_NAME[rc] ?? rc).join('')}'`
for (const prop of matchedRw.t1Codes) {
const rolledVal = prop.min === prop.max ? prop.min : prop.max
const zhLine = formatPropertyCode(prop.code, rolledVal, prop.min, prop.max, prop.param, 'zh')
const enLine = formatPropertyCode(prop.code, rolledVal, prop.min, prop.max, prop.param, 'en')
if (zhLine) addedZh.push({ text: zhLine, color: 'blue' })
if (enLine) addedEn.push({ text: enLine, color: 'blue' })
}
}
}
mutable.stats = insertStatLinesBeforeSocketLine(target.stats, addedZh)
if (target.statsEn !== undefined || addedEn.length > 0) {
mutable.statsEn = insertStatLinesBeforeSocketLine(target.statsEn ?? target.stats, addedEn)
}
applyStatDeltaToTargetBase(target, addedZh, addedEn)
if (target.rawItem) {
try {
const domainSocketable: Item =
socketable.rawItem ??
({
id: 0,
code,
name: socketable.name,
quality: isJewel ? 4 : 2,
level: sockReqLvl || 1,
invWidth: 1,
invHeight: 1,
identified: true,
base: {
id: code,
name: socketable.baseNameEn ?? socketable.name,
kind: 'misc',
type: isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem',
invWidth: 1,
invHeight: 1,
maxStack: 1,
value: 100,
damage: 0,
defense: 0,
tags: [isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem'],
level: sockReqLvl || 1,
},
rolledProps: [],
stats: {},
} as unknown as Item)
mutable.rawItem = socketItemIntoHost(target.rawItem, domainSocketable)
} catch {
// Keep UI target updated even if rawItem lacks full base metadata
}
}
return target
}
/**
* Draws the authentic Diablo II socket circles and any socketed Gem/Rune/Jewel icons
* overlaid on a socketed item in the Inventory, Equipment slot, Stash, or Horadric Cube.
*/
export function drawItemSocketOverlay(
ctx: CanvasRenderingContext2D,
item: UiInventoryItem,
x: number,
y: number,
w: number,
h: number,
assets: {
itemsAtlasImg: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
): void {
const totalSockets = Math.min(6, Math.max(0, item.sockets ?? 0))
if (totalSockets <= 0 || isUiItemUnidentified(item)) return
const socketed = item.socketedItems ?? []
const twoCols = totalSockets >= 4 && item.invWidth >= 2
const rows = twoCols ? Math.ceil(totalSockets / 2) : totalSockets
const radius = 9
for (let idx = 0; idx < totalSockets; idx++) {
let cx: number
let cy: number
if (twoCols) {
const col = idx % 2
const row = Math.floor(idx / 2)
const isLastSingleRow = idx === totalSockets - 1 && totalSockets % 2 === 1
cx = isLastSingleRow ? x + w / 2 : x + (col === 0 ? w * 0.32 : w * 0.68)
cy = y + ((row + 0.5) / rows) * h
} else {
cx = x + w / 2
cy = y + ((idx + 0.5) / rows) * h
}
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(10, 10, 14, 0.74)'
ctx.fill()
ctx.lineWidth = 1
ctx.strokeStyle = 'rgba(168, 152, 118, 0.78)'
ctx.stroke()
const sockItem = socketed[idx]
if (sockItem) {
const sr = resolveItemSpriteRect(sockItem, assets.itemRects)
if (assets.itemsAtlasImg && sr) {
const iconSize = 16
ctx.drawImage(
assets.itemsAtlasImg,
sr.x,
sr.y,
sr.w,
sr.h,
Math.round(cx - iconSize / 2),
Math.round(cy - iconSize / 2),
iconSize,
iconSize,
)
} else {
ctx.beginPath()
ctx.arc(cx, cy, 5, 0, Math.PI * 2)
ctx.fillStyle = isRuneCode(sockItem.code)
? '#e09840'
: isJewelCode(sockItem.code)
? '#68a8ff'
: '#e8d070'
ctx.fill()
}
}
ctx.restore()
}
}
export class InventoryPanel {
visible = false
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
@ -1597,6 +2262,8 @@ export class InventoryPanel {
* - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`.
* - If `cursorItem` is a Scroll (`tsc`/`isc`) and hits a matching Tome (`tbk`/`ibk`) with `quantity < 20`:
* stack the scroll into the tome (`quantity + 1`) and clear `cursorItem` (`Books.txt`).
* - If `cursorItem` is a Gem, Rune, or Jewel and hits a socketed item with open sockets:
* socket 1 unit of `cursorItem` into the target item (`D2Game.dll` `0x6fc479c0`).
* - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap!
*/
clickGridCell(col: number, row: number): boolean {
@ -1610,7 +2277,7 @@ export class InventoryPanel {
return true
}
// Check direct hit for Scroll-into-Tome stacking (`tsc` -> `tbk`, `isc` -> `ibk`)
// Check direct hit for Scroll-into-Tome stacking (`tsc` -> `tbk`, `isc` -> `ibk`) or Socketing
const directHit = this.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
@ -1629,6 +2296,16 @@ export class InventoryPanel {
return true
}
}
if (directHit && canSocketUiItem(this.cursorItem, directHit.item)) {
applySocketToUiItem(directHit.item, this.cursorItem)
const qty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(this.cursorItem, qty - 1)
} else {
this.cursorItem = null
}
return true
}
const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col))
const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row))
@ -1655,6 +2332,16 @@ export class InventoryPanel {
return true
}
}
if (canSocketUiItem(this.cursorItem, single.item)) {
applySocketToUiItem(single.item, this.cursorItem)
const qty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(this.cursorItem, qty - 1)
} else {
this.cursorItem = null
}
return true
}
this.gridItems = this.gridItems.filter(p => p !== single)
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
this.cursorItem = single.item
@ -1733,6 +2420,7 @@ export class InventoryPanel {
/**
* Handle left-click on an equipment slot (`helm`, `weapon1`, `armor`, etc.).
* Enforces Diablo II v1.13c item level/attribute/class requirements and weapon/shield hand-slot rules.
* Holding a Gem, Rune, or Jewel and left-clicking an equipped socketed item sockets it directly.
*/
clickEquipSlot(slotId: EquipSlotId): boolean {
const existing = this.equipped[slotId] ?? null
@ -1742,6 +2430,16 @@ export class InventoryPanel {
this.cursorItem = existing
return true
}
if (existing && canSocketUiItem(this.cursorItem, existing)) {
applySocketToUiItem(existing, this.cursorItem)
const qty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(this.cursorItem, qty - 1)
} else {
this.cursorItem = null
}
return true
}
if (!this.canEquipItemInSlot(this.cursorItem, slotId)) {
return false
}
@ -2114,5 +2812,6 @@ export class InventoryPanel {
ctx.strokeStyle = '#c8a15a'
ctx.strokeRect(x + 2, y + 2, w - 4, h - 4)
}
drawItemSocketOverlay(ctx, item, x, y, w, h, assets)
}
}

View File

@ -685,7 +685,14 @@ export function itemToUiInventoryItem(
const rawQ = rawItem.quality
const rawRarity = String(rawItem.rarity ?? '').toLowerCase()
if (isRuneCode(code) || (base as any)?.isRune || (rawItem as any)?.isRune || rawRarity === 'rune') {
if (
isRuneCode(code) ||
(base as any)?.isRune ||
(rawItem as any)?.isRune ||
rawRarity === 'rune' ||
rawRarity === 'runeword' ||
Boolean(rawItem.runeword || (rawItem as any).isRuneword)
) {
quality = 'rune'
} else if (rawQ === ItemQuality.UNIQUE || rawQ === 7 || rawRarity === 'unique' || Boolean(rawItem.uniqueItemDef)) {
quality = 'unique'
@ -738,6 +745,12 @@ export function itemToUiInventoryItem(
nameZh = mZh && mZh !== mEn ? mZh : mEn
baseNameZh = formattedBaseZh
baseNameEn = baseEn
} else if ((rawItem.runeword || (rawItem as any).isRuneword) && (rawItem.runeword?.name || (rawItem as any).runewordName)) {
const rwEn = (rawItem.runeword?.name || (rawItem as any).runewordName) as string
const rwZh = translateItemName(rwEn)
nameZh = rwZh && rwZh !== rwEn ? rwZh : rwEn
baseNameZh = formattedBaseZh
baseNameEn = baseEn
} else {
const itemEn = rawItem.name || baseEn
const itemZh = translateItemName(itemEn)
@ -851,6 +864,10 @@ export function itemToUiInventoryItem(
rawItem.totalSockets ??
rawItem.socketedCount ??
(rolledSockProp > 0 ? rolledSockProp : undefined)
const socketedItems =
!isGambleMystery && Array.isArray(rawItem.socketedItems) && rawItem.socketedItems.length > 0
? rawItem.socketedItems.map(s => itemToUiInventoryItem(s, dropTables))
: undefined
const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
const isUnidentified = Boolean(
@ -948,7 +965,7 @@ export function itemToUiInventoryItem(
code,
invFile,
baseInvFile,
name: rawItem.name || baseEn,
name: rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name || baseEn,
nameZh,
baseNameZh,
baseNameEn,
@ -975,10 +992,11 @@ export function itemToUiInventoryItem(
...(!isGambleMystery && chrtransform ? { chrtransform } : {}),
...(durability ? { durability } : {}),
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
...(socketedItems !== undefined ? { socketedItems } : {}),
...(ethereal ? { ethereal: true } : {}),
...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}),
...(!isGambleMystery && ((rawItem as any).speedTextEn || ttEn.speedText) ? { speedTextEn: (rawItem as any).speedTextEn || ttEn.speedText } : {}),
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes } : {}),
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}),
...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}),
...(!isGambleMystery && ttEn.setPieces ? { setPiecesEn: ttEn.setPieces } : {}),
...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),

View File

@ -14,7 +14,10 @@ import {
GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED,
TOME_MAX_QUANTITY,
applySocketToUiItem,
canPlayerUseUiItem,
canSocketUiItem,
drawItemSocketOverlay,
findFreeGridSlot,
getEffectiveItemReqClass,
getTomeQuantity,
@ -50,7 +53,7 @@ import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { isItemRepairable } from '../game/item-cost.ts'
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../data/canonical-tbl-strings.ts'
import { CANONICAL_ELITE_MODIFIERS, monsterDisplayName } from '../game/monsters.ts'
import { getSocketableInfo } from '../game/gems-data.ts'
import { getSocketableInfo, isJewelCode } from '../game/gems-data.ts'
export {
GRID_ITEM_BG_BLUE,
@ -1562,6 +1565,15 @@ export class WorldPanelsHud {
return { handled: true, newCursorItem: null }
}
}
if (directHit && canSocketUiItem(cursorItem, directHit.item)) {
applySocketToUiItem(directHit.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
if (
col < 0 ||
@ -1593,6 +1605,15 @@ export class WorldPanelsHud {
return { handled: true, newCursorItem: null }
}
}
if (canSocketUiItem(cursorItem, single.item)) {
applySocketToUiItem(single.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
this.stashItems = this.stashItems.filter(p => p !== single)
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: single.item }
@ -1687,6 +1708,15 @@ export class WorldPanelsHud {
return { handled: true, newCursorItem: null }
}
}
if (directHit && canSocketUiItem(cursorItem, directHit.item)) {
applySocketToUiItem(directHit.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
const targetCol = Math.min(CUBE_GRID_ORIGIN.cols - cursorItem.invWidth, Math.max(0, col))
const targetRow = Math.min(CUBE_GRID_ORIGIN.rows - cursorItem.invHeight, Math.max(0, row))
@ -1720,6 +1750,15 @@ export class WorldPanelsHud {
return { handled: true, newCursorItem: null }
}
}
if (canSocketUiItem(cursorItem, single.item)) {
applySocketToUiItem(single.item, cursorItem)
const qty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
if (qty > 1) {
setTomeQuantity(cursorItem, qty - 1)
return { handled: true, newCursorItem: cursorItem }
}
return { handled: true, newCursorItem: null }
}
this.cubeItems = this.cubeItems.filter(p => p !== single)
this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow })
return { handled: true, newCursorItem: single.item }
@ -2450,6 +2489,10 @@ export class WorldPanelsHud {
placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8'
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
}
drawItemSocketOverlay(ctx, placed.item, gx, gy, gw, gh, {
itemsAtlasImg: atlas,
itemRects: BAKED_UI_MANIFEST.itemRects,
})
}
} else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
@ -2509,6 +2552,10 @@ export class WorldPanelsHud {
placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8'
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
}
drawItemSocketOverlay(ctx, placed.item, gx, gy, gw, gh, {
itemsAtlasImg: atlas,
itemRects: BAKED_UI_MANIFEST.itemRects,
})
}
// D2Client.dll `0x6fb49615..0x6fb49656`: Cube Close button (`buysellbtn.dc6` frame 10 or 11 at `ox + 275, oy + 383`)
@ -3144,16 +3191,40 @@ export class WorldPanelsHud {
}
}
} else {
// Magic Affixes
const socketableInfo = isEn && !item.statsEn && item.code ? getSocketableInfo(item.code) : undefined
if (isEn && socketableInfo) {
// Magic Affixes & Socketable Instructions
const socketableInfo = item.code ? getSocketableInfo(item.code) : undefined
if (isEn && socketableInfo && !item.statsEn) {
lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white', font: 'font8' })
lines.push({ text: `Weapons: ${socketableInfo.weaponsEn}`, color: 'white', font: 'font8' })
lines.push({ text: `Armor: ${socketableInfo.armorEn}`, color: 'white', font: 'font8' })
lines.push({ text: `Helms: ${socketableInfo.helmsEn}`, color: 'white', font: 'font8' })
lines.push({ text: `Shields: ${socketableInfo.shieldsEn}`, color: 'white', font: 'font8' })
} else if (!isEn && socketableInfo && item.stats.length === 0) {
lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white', font: 'font8' })
lines.push({ text: `武器: ${socketableInfo.weaponsZh}`, color: 'white', font: 'font8' })
lines.push({ text: `盔甲: ${socketableInfo.armorZh}`, color: 'white', font: 'font8' })
lines.push({ text: `头盔: ${socketableInfo.helmsZh}`, color: 'white', font: 'font8' })
lines.push({ text: `盾牌: ${socketableInfo.shieldsZh}`, color: 'white', font: 'font8' })
} else {
const activeStats = isEn ? (item.statsEn ?? item.stats) : item.stats
const isJewel =
isJewelCode(item.code) ||
Boolean(item.baseNameZh?.includes('珠宝')) ||
item.baseNameEn?.trim().toLowerCase() === 'jewel'
if (
isJewel &&
!activeStats.some(
s =>
s.text.includes('可镶嵌进有凹槽的装备') ||
s.text.includes('Can be Inserted into Socketed Items'),
)
) {
lines.push({
text: isEn ? 'Can be Inserted into Socketed Items' : '可镶嵌进有凹槽的装备',
color: 'white',
font: 'font8',
})
}
for (const st of activeStats) {
lines.push({
text: localizeStarterTooltipLine(st.text, isEn),
@ -3671,6 +3742,7 @@ const STARTER_STAT_EN_EXACT: Record<string, string> = {
'宝石': 'Gem',
'骷髅': 'Skull',
'卷轴': 'Scroll',
'可镶嵌进有凹槽的装备': 'Can be Inserted into Socketed Items',
'右键点击打开合成界面 (3×4 网格)': 'Right-Click to Open Horadric Cube (3x4 Grid)',
'右键施放城镇传送门': 'Right-Click to Cast Town Portal',
'鉴定未辨识的魔法/暗金装备': 'Right-Click to Identify Unidentified Items',

472
tests/socket-system.test.ts Normal file
View File

@ -0,0 +1,472 @@
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)
})
})
})