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:
parent
6601dcde82
commit
768a2cc920
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
460
src/game/gems.ts
460
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<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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })) } : {}),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue