From 768a2cc92071b29347e40085b84a4693fa552be7 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 12:44:31 +0000 Subject: [PATCH] feat(socket): implement 1.13c jewel, gem & rune socketing system (Closes #507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/game/gems-data.ts | 65 ++++ src/game/gems.ts | 460 ++++++++++++++++++++++- src/game/item-tooltip.ts | 76 +++- src/game/items.ts | 2 + src/ui/inventory.ts | 703 +++++++++++++++++++++++++++++++++++- src/ui/item-bridge.ts | 24 +- src/ui/world-panels.ts | 80 +++- tests/socket-system.test.ts | 472 ++++++++++++++++++++++++ 8 files changed, 1869 insertions(+), 13 deletions(-) create mode 100644 tests/socket-system.test.ts diff --git a/src/game/gems-data.ts b/src/game/gems-data.ts index 7b6cb03..bd98796 100644 --- a/src/game/gems-data.ts +++ b/src/game/gems-data.ts @@ -1210,6 +1210,15 @@ export const RUNE_INFO: Record = { } } +/** + * 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) +} + diff --git a/src/game/gems.ts b/src/game/gems.ts index 6d324d8..0b970e0 100644 --- a/src/game/gems.ts +++ b/src/game/gems.ts @@ -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 = { ...(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)) } + diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index ad1da28..0a95485 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -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 } : {}), } } diff --git a/src/game/items.ts b/src/game/items.ts index c248ff7..8d003ca 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -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 } diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 1021a40..3490e0c 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -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() + 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 + + 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 + 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 + }, +): 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) } } diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index ef50fce..f7245dd 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -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 })) } : {}), diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 2a3f6f7..113bf8d 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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 = { '宝石': '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', diff --git a/tests/socket-system.test.ts b/tests/socket-system.test.ts new file mode 100644 index 0000000..abc2876 --- /dev/null +++ b/tests/socket-system.test.ts @@ -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) + }) + }) +})