feat(runeword): implement authentic 1.13c Runeword, socketing & Horadric Cube transmutation system

This commit is contained in:
troytt 2026-09-28 13:40:16 +00:00
parent 1d7445a64e
commit fc1085418c
8 changed files with 2012 additions and 36 deletions

View File

@ -829,7 +829,7 @@ export function createDroppedItem(
(max, p) => (p.code.trim().toLowerCase() === 'sock' ? Math.max(max, p.value) : max),
0,
)
const maxBaseSockets = (base as any).gemSockets
const maxBaseSockets = (base as any).gemsockets ?? (base as any).gemSockets
const unclampedSockets = Math.max(
options.sockets !== undefined && options.sockets > 0 ? options.sockets : 0,
propSockets,

View File

@ -29,6 +29,7 @@ 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 { getEmbeddedDropTables } from './embedded-drop-tables.ts'
import {
isGemCode,
isRuneCode,
@ -37,8 +38,9 @@ import {
RUNE_INFO,
type SocketTargetCategory,
} from './gems-data.ts'
import { ItemQuality, type Item, type RolledItemProp } from './items.ts'
import { ItemFlag, ItemQuality, type Item, type RolledItemProp } from './items.ts'
import { checkRunewordMatch, isNormalQuality } from './runeword.ts'
import { translateItemName } from './tooltip-i18n.ts'
/** File path of `Gems.txt` inside MPQ archives. */
export const GEMS_TABLE_PATH = 'data\\global\\excel\\Gems.txt'
@ -747,6 +749,14 @@ function defaultRunewordIsA(child: string, parent: string): boolean {
const p = parent.trim().toLowerCase()
if (!c || !p) return false
if (c === p) return true
if (p === 'pala' && c === 'ashd') return true
try {
const tables = getEmbeddedDropTables()
if (tables.isA(c, p)) return true
if (p === 'pala' && tables.isA(c, 'ashd')) return true
} catch {
// fallback to static sets below
}
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)
@ -850,17 +860,30 @@ export function socketItemIntoHost(
mergedStats[p.code] = (mergedStats[p.code] ?? 0) + p.value
}
const rwName = rwMatch.runeName || rwMatch.name
const rwNameZh =
translateItemName(rwMatch.name) !== rwMatch.name
? translateItemName(rwMatch.name)
: translateItemName(rwName)
updatedHost = {
...updatedHost,
name: rwName,
nameZh: rwNameZh,
rarity: 'runeword',
runeword: {
id: rwMatch.runewordId,
name: rwName,
key: rwMatch.name,
},
flags: {
...host.flags,
identified: true,
socketed: true,
runeword: true,
},
rawFlags: (((host.rawFlags ?? 0) | ItemFlag.IDENTIFIED | ItemFlag.SOCKETED | ItemFlag.RUNEWORD) >>> 0),
rolledProps: mergedProps,
stats: mergedStats,
} as Item
} as unknown as Item
}
}

View File

@ -1854,7 +1854,32 @@ function getAffixEffectiveLevelReq(aff: RolledAffix, itemClass: string): number
*/
export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltipLocale()): FormattedItemTooltip {
const base = item.base
const quality = String(item.rarity || item.quality || 'normal').toLowerCase()
const rawQuality = String(item.rarity || item.quality || 'normal').toLowerCase()
const isRunewordItem = Boolean(
rawQuality === 'runeword' ||
item.flags?.runeword ||
(item as any).runeword ||
(item as any).isRuneword,
)
const quality = isRunewordItem
? 'runeword'
: rawQuality === '2'
? 'normal'
: rawQuality === '3'
? 'superior'
: rawQuality === '1'
? 'low'
: rawQuality === '4'
? 'magic'
: rawQuality === '5'
? 'set'
: rawQuality === '6'
? 'rare'
: rawQuality === '7'
? 'unique'
: rawQuality === '8'
? 'craft'
: rawQuality
const ilvl = item.level ?? item.ilvl ?? 1
const itemClass = resolveItemClass(item)
@ -2177,12 +2202,23 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
}
} else if (quality === 'superior') {
qualityColor = '#ffffff'
const enName = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${baseNameEn}`
const hasSocketed = Boolean(
(item.socketedItems && item.socketedItems.length > 0) ||
(item.name && item.name.startsWith('Gemmed ')),
)
qualityColor = hasSocketed ? '#a0a0a0' : '#ffffff'
const enName = hasSocketed
? `Gemmed ${baseNameEn}`
: item.name && item.name.startsWith('Superior ')
? item.name
: `Superior ${baseNameEn}`
const zhName = hasSocketed
? `镶嵌的${translateItemName(baseNameEn)}`
: translateItemName(enName)
if (locale === 'zh') {
title = translateItemName(enName)
title = zhName
} else if (locale === 'bilingual') {
title = `${enName} (${translateItemName(enName)})`
title = `${enName} (${zhName})`
} else {
title = enName
}
@ -2195,11 +2231,22 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
} else if (quality === 'low') {
qualityColor = '#a0a0a0'
const enName = item.name && item.name !== base.name ? item.name : `Cracked ${baseNameEn}`
const hasSocketed = Boolean(
(item.socketedItems && item.socketedItems.length > 0) ||
(item.name && item.name.startsWith('Gemmed ')),
)
const enName = hasSocketed
? `Gemmed ${baseNameEn}`
: item.name && item.name !== base.name
? item.name
: `Cracked ${baseNameEn}`
const zhName = hasSocketed
? `镶嵌的${translateItemName(baseNameEn)}`
: translateItemName(enName)
if (locale === 'zh') {
title = translateItemName(enName)
title = zhName
} else if (locale === 'bilingual') {
title = `${enName} (${translateItemName(enName)})`
title = `${enName} (${zhName})`
} else {
title = enName
}
@ -2208,17 +2255,31 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
} else if (quality === 'runeword' || (quality === 'rune' && !socketable)) {
qualityColor = '#c8a15a'
const rwTitle = (item as any).nameZh || (item as any).runeword?.name || (item as any).runewordName || item.name || 'Runeword'
const rwKey =
(item as any).runeword?.key ||
(item as any).runeword?.name ||
(item as any).runewordName ||
item.name ||
'Runeword'
const rwEn =
(item as any).runeword?.name && !(item as any).runeword.name.startsWith('Runeword')
? String((item as any).runeword.name)
: CANONICAL_ENG_TBL_DICT[rwKey] || String(item.name || rwKey)
const rwZhRaw = (item as any).nameZh
? String((item as any).nameZh).replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
: translateItemName(rwKey) !== rwKey
? translateItemName(rwKey)
: translateItemName(rwEn)
const rwZh = rwZhRaw || rwEn
const bZh = (item as any).baseNameZh || translateItemName(baseNameEn)
if (locale === 'zh') {
title = (item as any).nameZh || translateItemName(rwTitle) || rwTitle
title = rwZh
subTitle = bZh
} else if (locale === 'bilingual') {
const rwZh = translateItemName(rwTitle)
title = rwZh !== rwTitle ? `${rwTitle} (${rwZh})` : rwTitle
title = rwZh !== rwEn ? `${rwEn} (${rwZh})` : rwEn
subTitle = `${baseNameEn} (${bZh})`
} else {
title = rwTitle
title = rwEn
subTitle = baseNameEn
}
const rwLevelReq = (item as any).runeword?.levelreq || (item as any).reqLevel
@ -2241,12 +2302,26 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
}
} else {
qualityColor = '#e0d5c1'
const enName = baseNameEn
const hasSocketed = Boolean(
(item.socketedItems && item.socketedItems.length > 0) ||
(item.name && item.name.startsWith('Gemmed ')),
)
const hasSockets = Boolean(
hasSocketed ||
item.flags?.socketed ||
(item.totalSockets && item.totalSockets > 0) ||
((item as any).sockets && (item as any).sockets > 0),
)
const isEth = Boolean(item.flags?.ethereal || (item as any).ethereal || item.stats?.ethereal)
qualityColor = hasSocketed || hasSockets || isEth ? '#a0a0a0' : '#e0d5c1'
const enName = hasSocketed ? `Gemmed ${baseNameEn}` : baseNameEn
const zhName = hasSocketed
? `镶嵌的${translateItemName(baseNameEn)}`
: translateItemName(baseNameEn)
if (locale === 'zh') {
title = translateItemName(enName)
title = zhName
} else if (locale === 'bilingual') {
title = `${enName} (${translateItemName(enName)})`
title = `${enName} (${zhName})`
} else {
title = enName
}
@ -2298,7 +2373,9 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
}
if (!derivedRunewordRunes && runeLetters.length > 0) {
derivedRunewordRunes = runeLetters.join('')
derivedRunewordRunes = `'${runeLetters.join('')}'`
} else if (derivedRunewordRunes && !derivedRunewordRunes.startsWith("'")) {
derivedRunewordRunes = `'${derivedRunewordRunes}'`
}
}

1250
src/game/runeword-socket.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
* - 'bilingual': Bilingual title (English + Chinese) with Chinese property lines
*/
import { resolveSkillInfo } from './skills-data.ts'
import { resolveSkillInfo, CANONICAL_SKILL_TABS, CANONICAL_REANIMATE_MONSTERS } from './skills-data.ts'
import { CANONICAL_ENG_TBL_DICT, CANONICAL_CHI_TBL_DICT } from '../data/canonical-tbl-strings.ts'
export type TooltipLocale = 'en' | 'zh' | 'bilingual'
@ -1371,7 +1371,169 @@ export const ITEM_NAMES_ZH: Record<string, string> = {
"McAuley's Paragon": "山德的模范",
"McAuley's Riprap": "山德的碎石",
"McAuley's Taboo": "山德的禁忌",
"McAuley's Superstition": "山德的迷信"
"McAuley's Superstition": "山德的迷信",
// 1.13c Complete Runewords (78 Runewords: .tbl keys + English names)
"Runeword1": "远古誓言",
"Ancients' Pledge": "远古誓言",
"Ancient's Pledge": "远古誓言",
"Runeword4": "野兽",
"Beast": "野兽",
"The Beast": "野兽",
"Runeword6": "黑色",
"Black": "黑色",
"Runeword8": "骸骨",
"Bone": "骸骨",
"Runeword9": "野蔷薇",
"Bramble": "野蔷薇",
"Runeword10": "烙印",
"Brand": "烙印",
"Runeword11": "死亡呼吸",
"Breath of the Dying": "死亡呼吸",
"Runeword13": "战争召唤",
"Call to Arms": "战争召唤",
"Runeword14": "荣耀之链",
"Chains of Honor": "荣耀之链",
"Bound by Duty": "荣耀之链",
"Runeword16": "混沌",
"Chaos": "混沌",
"Runeword17": "新月",
"Runeword20": "死神",
"Death": "死神",
"Runeword22": "迪勒瑞姆",
"Delirium": "迪勒瑞姆",
"Runeword25": "毁灭",
"Destruction": "毁灭",
"Runeword26": "末日",
"Doom": "末日",
"Doomsayer": "末日",
"Runeword27": "飞龙",
"Dragon": "飞龙",
"Runeword29": "梦境",
"Dream": "梦境",
"Runeword30": "强制",
"Duress": "强制",
"Runeword31": "边缘",
"Edge": "边缘",
"Runeword33": "谜团",
"Enigma": "谜团",
"Runeword34": "启迪",
"Enlightenment": "启迪",
"Runeword36": "永恒",
"Eternity": "永恒",
"Runeword37": "流亡",
"Exile": "流亡",
"Exile's Path": "流亡",
"Runeword38": "信心",
"Faith": "信心",
"Runeword39": "饥荒",
"Famine": "饥荒",
"Runeword41": "刚毅",
"Fortitude": "刚毅",
"Runeword44": "狂怒",
"Fury": "狂怒",
"Runeword45": "幽暗",
"Gloom": "幽暗",
"Runeword47": "悔恨",
"Grief": "悔恨",
"Runeword48": "正义之手",
"Hand of Justice": "正义之手",
"Runeword49": "和谐",
"Harmony": "和谐",
"Runeword51": "橡树之心",
"Heart of the Oak": "橡树之心",
"Runeword54": "圣雷",
"Holy Thunder": "圣雷",
"Runeword55": "荣耀",
"Honor": "荣耀",
"Runeword59": "寒冰",
"Ice": "寒冰",
"Runeword60": "无限",
"Infinity": "无限",
"Runeword62": "眼光",
"Insight": "眼光",
"Runeword65": "国王之恩典",
"King's Grace": "国王之恩典",
"Runeword66": "弑君者",
"Kingslayer": "弑君者",
"Runeword69": "最后希望",
"Last Wish": "最后希望",
"Runeword71": "执法者",
"Lawbringer": "执法者",
"Runeword72": "叶",
"Leaf": "叶",
"Runeword74": "狮心",
"Lionheart": "狮心",
"Runeword75": "知识",
"Lore": "知识",
"Runeword81": "怨恨",
"Malice": "怨恨",
"Runeword82": "旋律",
"Melody": "旋律",
"Runeword83": "记忆",
"Memory": "记忆",
"Runeword87": "神话",
"Myth": "神话",
"Runeword88": "底韵",
"Nadir": "底韵",
"Runeword91": "誓约",
"Oath": "誓约",
"Runeword92": "遵从",
"Obedience": "遵从",
"Runeword95": "热情",
"Passion": "热情",
"Runeword98": "和平",
"Peace": "和平",
"Runeword99": "理性之声",
"Voice of Reason": "理性之声",
"Winter": "理性之声",
"Runeword103": "凤凰",
"Phoenix": "凤凰",
"Runeword109": "骄傲",
"Pride": "骄傲",
"Runeword110": "原则",
"Principle": "原则",
"Runeword112": "审慎",
"Prudence": "审慎",
"Runeword116": "光辉",
"Radiance": "光辉",
"Runeword117": "雨",
"Rain": "雨",
"Runeword120": "押韵",
"Rhyme": "押韵",
"Runeword121": "裂隙",
"Rift": "裂隙",
"Runeword122": "圣堂",
"Sanctuary": "圣堂",
"Runeword126": "寂静",
"Silence": "寂静",
"Runeword128": "烟雾",
"Smoke": "烟雾",
"Runeword130": "精神",
"Spirit": "精神",
"Runeword131": "灿烂",
"Splendor": "灿烂",
"Runeword133": "隐秘",
"Stealth": "隐秘",
"Runeword134": "钢铁",
"Steel": "钢铁",
"Runeword137": "石块",
"Stone": "石块",
"Runeword139": "力量",
"Strength": "力量",
"Runeword148": "背信",
"Treachery": "背信",
"Runeword154": "毒液",
"Venom": "毒液",
"Runeword160": "财富",
"Wealth": "财富",
"Runeword162": "白色",
"White": "白色",
"Runeword163": "风",
"Wind": "风",
"Runeword168": "愤怒",
"Wrath": "愤怒",
"Runeword170": "和风",
"Zephyr": "和风"
}
/**
@ -2642,14 +2804,14 @@ export const SKILL_NAMES_ZH: Record<string, string> = {
"Scroll of Townportal": "城镇传送卷轴",
"Tome of Townportal": "城镇传送之书",
"Book of Townportal": "城镇传送之书",
"Raven": "渡鸟之",
"Raven": "乌鸦",
"Werewolf": "狼人变化",
"Wearwolf": "狼人变化",
"Maul": "大木槌的",
"Maul": "撞槌",
"Fissure": "裂缝",
"Eruption": "裂缝",
"Armageddon": "世界末日之",
"Venom": "毒液的",
"Armageddon": "毁天灭地",
"Venom": "剧毒",
"Delirium": "迪勒瑞姆",
"Delerium Change": "迪勒瑞姆",
"Poison Creeper": "剧毒蔓藤",
@ -2986,6 +3148,11 @@ export function translateItemName(enName: string): string {
const sub = trimmed.slice('Superior '.length).trim()
return '超强的' + translateItemName(sub)
}
// Gemmed prefix (socketed non-runeword base items)
if (trimmed.startsWith('Gemmed ')) {
const sub = trimmed.slice('Gemmed '.length).trim()
return '镶嵌的' + translateItemName(sub)
}
// Low quality prefixes
if (trimmed.startsWith('Cracked ')) {
const sub = trimmed.slice('Cracked '.length).trim()
@ -3714,10 +3881,26 @@ function formatPropertyCodeEn(
case 'randclassskill': return `+${effectiveVal || 3} to Random Class Skill Levels`
case 'classskills': return `${plusVal} to ${par ? `${par} ` : ''}Skill Levels`
case 'fireskill': return `${plusVal} to Fire Skills`
case 'skilltab': return `${plusVal} to ${par ?? 'Skill Tab'}`
case 'skill': return `${plusVal} to ${par ?? 'Skill'}`
case 'skill-rand': return `+${par || 3} to Skill`
case 'oskill': return `${plusVal} to ${par ?? 'Skill'}`
case 'skilltab': {
const tabId = Number(par)
if (!Number.isNaN(tabId) && CANONICAL_SKILL_TABS[tabId]) {
return `${plusVal} to ${CANONICAL_SKILL_TABS[tabId]}`
}
return `${plusVal} to ${par ?? 'Skill Tab'}`
}
case 'skill': {
const sk = resolveSkillInfo(par)
return `${plusVal} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ''}`
}
case 'skill-rand': {
const slvl = Number(par) || effectiveVal || 3
const sk = resolveSkillInfo(min)
return `+${slvl} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ' (Sorceress Only)'}`
}
case 'oskill': {
const sk = resolveSkillInfo(par)
return `${plusVal} to ${sk.name}`
}
case 'aura': {
const sk = resolveSkillInfo(par)
return `Level ${effectiveVal} ${sk.name} Aura When Equipped`
@ -3892,7 +4075,13 @@ function formatPropertyCodeEn(
case 'addxp': return `${plusVal}% to Experience Gained`
case 'magicarrow': return 'Fires Magic Arrows'
case 'explosivearrow': return 'Fires Explosive Arrows or Bolts'
case 'reanimate': return `${effectiveVal}% Reanimate as: ${par || 'Returned'}`
case 'reanimate': {
const monId = Number(par)
const monName = !Number.isNaN(monId) && CANONICAL_REANIMATE_MONSTERS[monId]
? CANONICAL_REANIMATE_MONSTERS[monId]
: (par || 'Returned')
return `${effectiveVal}% Reanimate as: ${monName}`
}
case 'bloody': return 'Extra Blood'
case 'fade': return 'Fade'
case 'state': return String(par).toLowerCase() === 'monsterset' ? 'Transforms into Vampire' : 'Display Aura'

View File

@ -691,7 +691,7 @@ export function itemToUiInventoryItem(
(rawItem as any)?.isRune ||
rawRarity === 'rune' ||
rawRarity === 'runeword' ||
Boolean(rawItem.runeword || (rawItem as any).isRuneword)
Boolean(rawItem.runeword || rawItem.flags?.runeword || (rawItem as any).isRuneword)
) {
quality = 'rune'
} else if (rawQ === ItemQuality.UNIQUE || rawQ === 7 || rawRarity === 'unique' || Boolean(rawItem.uniqueItemDef)) {
@ -745,9 +745,16 @@ 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)
} else if (
(rawItem.runeword || rawItem.flags?.runeword || (rawItem as any).isRuneword) &&
(rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name)
) {
const rwKey = String((rawItem.runeword as any)?.key || rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name)
const rwEn = String(rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name)
const rwZh =
translateItemName(rwKey) !== rwKey
? translateItemName(rwKey)
: translateItemName(rwEn)
nameZh = rwZh && rwZh !== rwEn ? rwZh : rwEn
baseNameZh = formattedBaseZh
baseNameEn = baseEn

View File

@ -54,6 +54,10 @@ 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, isJewelCode } from '../game/gems-data.ts'
import {
transmuteCubeItems,
type CubeTransmuteResult,
} from '../game/runeword-socket.ts'
export {
GRID_ITEM_BG_BLUE,
@ -1766,6 +1770,17 @@ export class WorldPanelsHud {
return { handled: false, newCursorItem: cursorItem }
}
/**
* Executes Horadric Cube transmutation recipes (`CubeMain.txt`) on `this.cubeItems`.
*/
transmuteCube(): CubeTransmuteResult {
const res = transmuteCubeItems(this.cubeItems)
if (res.matched) {
this.cubeItems = [...res.resultItems]
}
return res
}
quickTransferToCube(item: UiInventoryItem, fromInventory: InventoryPanel): boolean {
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
if (hitIdx === -1) return false
@ -2079,6 +2094,7 @@ export class WorldPanelsHud {
if (!callbacks.isRightClick) {
this.pressedCubeButton = 'transmute'
this.cubeTransmuteCount += 1
this.transmuteCube()
}
return true
}

View File

@ -0,0 +1,414 @@
import { describe, expect, it } from 'vitest'
import {
CANONICAL_RUNES_TABLE,
type RunewordDefinition,
} from '../src/game/runeword.ts'
import {
canSocketItem,
createSocketableItem,
createSocketedBaseItem,
getFilledSocketCount,
getTotalSocketCount,
resolveGemApplyType,
socketItemWithSubItem,
transmuteCubeItems,
} from '../src/game/runeword-socket.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { ItemFlag, ItemQuality, type ItemBase } from '../src/game/items.ts'
import { Rng } from '../src/game/rng.ts'
import { formatItemTooltip } from '../src/game/item-tooltip.ts'
import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts'
import { InventoryPanel } from '../src/ui/inventory.ts'
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
/**
* Finds a canonical 1.13c base item from `weapons` or `armor` whose `type` matches
* `rw.itypes` and does not match `rw.etypes`.
*/
function findMatchingBaseForRuneword(rw: RunewordDefinition): ItemBase {
const tables = getEmbeddedDropTables()
const allBases: ItemBase[] = [
...tables.weapons.all,
...tables.armor.all,
]
for (const base of allBases) {
const typeCode = (base.type || '').trim().toLowerCase()
if (!typeCode) continue
const matchesItype = rw.itypes.some((it: string) => tables.isA(typeCode, it))
if (!matchesItype) continue
const matchesEtype = rw.etypes.some((et: string) => tables.isA(typeCode, et))
if (matchesEtype) continue
return base
}
throw new Error(`No matching 1.13c base item found for Runeword ${rw.name} (${rw.runeName}) with itypes=${rw.itypes.join(',')}`)
}
describe('Diablo II v1.13c Authentic Runeword & Socketing System', () => {
it('completes all 78 active 1.13c Runewords end-to-end with valid stats and bilingual tooltips', () => {
expect(CANONICAL_RUNES_TABLE.complete.length).toBe(78)
for (const rw of CANONICAL_RUNES_TABLE.complete) {
const base = findMatchingBaseForRuneword(rw)
const baseCode = ((base as any).code || base.id).trim()
const totalSockets = rw.runes.length
let currentItem = createSocketedBaseItem(baseCode, totalSockets, { ilvl: 85 })
const rng = new Rng(0x1337beef)
for (let i = 0; i < rw.runes.length; i++) {
const runeCode = rw.runes[i]!
const runeItem = createSocketableItem(runeCode)
const check = canSocketItem(currentItem, runeItem)
expect(check, `${rw.runeName} socket step ${i + 1} (${runeCode}) into ${baseCode}`).toBe(true)
const res = socketItemWithSubItem(currentItem, runeItem, rng)
expect(res.success).toBe(true)
currentItem = res.item
if (i < rw.runes.length - 1) {
// Partial socketing: must be Gemmed <BaseName>, NOT a Runeword yet
expect(res.runewordMatch).toBeUndefined()
expect(currentItem.flags?.runeword).toBe(false)
expect((currentItem.rawFlags! & ItemFlag.RUNEWORD) !== 0).toBe(false)
expect(currentItem.name.startsWith('Gemmed ')).toBe(true)
expect(currentItem.nameZh?.startsWith('镶嵌的')).toBe(true)
const partialTt = formatItemTooltip(currentItem, 'bilingual')
// Socketed normal base has grey title (#a0a0a0)
expect(partialTt.qualityColor).toBe('#a0a0a0')
expect(partialTt.title.startsWith('Gemmed ')).toBe(true)
} else {
// Final rune inserted: must complete the Runeword!
expect(res.runewordMatch).toBeDefined()
expect(res.runewordMatch?.name).toBe(rw.name)
expect(currentItem.flags?.runeword).toBe(true)
expect((currentItem.rawFlags! & ItemFlag.RUNEWORD) !== 0).toBe(true)
expect(currentItem.rarity).toBe('runeword')
expect(currentItem.socketedItems?.length).toBe(totalSockets)
// Verify UiInventoryItem bridge fields
const uiItem = res.uiItem
expect(uiItem.quality).toBe('rune')
expect(uiItem.runewordRunes).toMatch(/^'[A-Za-z]+'$/)
expect(uiItem.sockets).toBe(totalSockets)
// Verify English, Chinese, and Bilingual tooltips
const ttEn = formatItemTooltip(currentItem, 'en')
const ttZh = formatItemTooltip(currentItem, 'zh')
const ttBoth = formatItemTooltip(currentItem, 'bilingual')
expect(ttEn.qualityColor).toBe('#c8a15a')
expect(ttZh.qualityColor).toBe('#c8a15a')
expect(ttBoth.qualityColor).toBe('#c8a15a')
expect(ttEn.runewordRunes).toBe(uiItem.runewordRunes)
expect(ttEn.subTitle).toBeTruthy()
expect(ttZh.subTitle).toBeTruthy()
// Must not leak raw unformatted property strings
for (const line of ttBoth.lines) {
expect(line.text).not.toMatch(/undefined|NaN/)
}
}
}
}
})
it('applies slot-specific GemApplyType modifiers (Weapon=0 vs Armor/Helm=1 vs Shield=2)', () => {
// 1. Spirit (Tal + Thul + Ort + Amn) in Crystal Sword (Weapon, applyType=0) vs Monarch (Shield, applyType=2)
const spiritRunes = ['r07', 'r10', 'r09', 'r11']
let sword = createSocketedBaseItem('crs', 4, { ilvl: 80 })
let shield = createSocketedBaseItem('uit', 4, { ilvl: 80 })
expect(resolveGemApplyType(sword)).toBe(0)
expect(resolveGemApplyType(shield)).toBe(2)
for (const r of spiritRunes) {
sword = socketItemWithSubItem(sword, createSocketableItem(r), new Rng(42)).item
shield = socketItemWithSubItem(shield, createSocketableItem(r), new Rng(42)).item
}
expect(sword.flags?.runeword).toBe(true)
expect(shield.flags?.runeword).toBe(true)
const swordPropCodes = new Set(sword.rolledProps?.map(p => p.code))
const shieldPropCodes = new Set(shield.rolledProps?.map(p => p.code))
// Weapon Spirit gets lifesteal (Amn), dmg-ltng (Ort), dmg-cold (Thul), dmg-pois (Tal)
expect(swordPropCodes.has('lifesteal')).toBe(true)
expect(swordPropCodes.has('res-cold')).toBe(false)
// Shield Spirit gets res-cold (Thul), res-ltng (Ort), res-pois (Tal), thorns (Amn)
expect(shieldPropCodes.has('res-cold')).toBe(true)
expect(shieldPropCodes.has('res-ltng')).toBe(true)
expect(shieldPropCodes.has('res-pois')).toBe(true)
expect(shieldPropCodes.has('thorns')).toBe(true)
expect(shieldPropCodes.has('lifesteal')).toBe(false)
// 2. Fortitude (El + Sol + Dol + Lo) in Berserker Axe (Weapon, applyType=0) vs Archon Plate (Armor, applyType=1)
const fortRunes = ['r01', 'r12', 'r14', 'r28']
let axe = createSocketedBaseItem('7wa', 4, { ilvl: 85 })
let armor = createSocketedBaseItem('utp', 4, { ilvl: 85 })
expect(resolveGemApplyType(axe)).toBe(0)
expect(resolveGemApplyType(armor)).toBe(1)
for (const r of fortRunes) {
axe = socketItemWithSubItem(axe, createSocketableItem(r), new Rng(99)).item
armor = socketItemWithSubItem(armor, createSocketableItem(r), new Rng(99)).item
}
expect(axe.flags?.runeword).toBe(true)
expect(armor.flags?.runeword).toBe(true)
const axePropCodes = new Set(axe.rolledProps?.map(p => p.code))
const armorPropCodes = new Set(armor.rolledProps?.map(p => p.code))
// Weapon Fortitude gets deadly (Lo = 20% Deadly Strike) and howl (Dol = Hit Causes Monster to Flee)
expect(axePropCodes.has('deadly')).toBe(true)
expect(axePropCodes.has('howl')).toBe(true)
// Armor Fortitude gets res-ltng-max (Lo = +5% Max Lightning Resist) and red-dmg (Sol = Damage Reduced By 7)
expect(armorPropCodes.has('res-ltng-max')).toBe(true)
expect(armorPropCodes.has('red-dmg')).toBe(true)
})
it('enforces strict 1.13c Runeword invariants (exact sockets, exact rune order, item type hierarchy, normal/superior quality)', () => {
// 1. Wrong rune order: Jah + Thul + Ber in 3-socket Mage Plate ("Enigma" fail)
let wrongOrderArmor = createSocketedBaseItem('xtp', 3, { ilvl: 85 })
for (const r of ['r31', 'r10', 'r30']) {
wrongOrderArmor = socketItemWithSubItem(wrongOrderArmor, createSocketableItem(r)).item
}
expect(wrongOrderArmor.flags?.runeword).toBe(false)
expect(wrongOrderArmor.name).toBe('Gemmed Mage Plate')
expect(wrongOrderArmor.nameZh).toBe('镶嵌的法师铠甲')
// 2. Extra sockets: Stealth (Tal + Eth) in a 3-socket Mage Plate
let threeSocketArmor = createSocketedBaseItem('xtp', 3, { ilvl: 85 })
threeSocketArmor = socketItemWithSubItem(threeSocketArmor, createSocketableItem('r07')).item
threeSocketArmor = socketItemWithSubItem(threeSocketArmor, createSocketableItem('r05')).item
expect(threeSocketArmor.flags?.runeword).toBe(false)
expect(threeSocketArmor.name).toBe('Gemmed Mage Plate')
// 3. Wrong item type: Insight (Ral + Tir + Tal + Sol) requires pole/staf; must fail on a Spear ('spr')
let spear = createSocketedBaseItem('spr', 4, { ilvl: 85 })
for (const r of ['r08', 'r03', 'r07', 'r12']) {
spear = socketItemWithSubItem(spear, createSocketableItem(r)).item
}
expect(spear.flags?.runeword).toBe(false)
expect(spear.name).toBe('Gemmed Spear')
// 4. Heart of the Oak (Ko + Vex + Pul + Thul) requires staf/mace; fails on Crystal Sword ('crs'), succeeds on Flail ('fla')
let swordHoto = createSocketedBaseItem('crs', 4, { ilvl: 85 })
let flailHoto = createSocketedBaseItem('fla', 4, { ilvl: 85 })
for (const r of ['r18', 'r26', 'r21', 'r10']) {
swordHoto = socketItemWithSubItem(swordHoto, createSocketableItem(r)).item
flailHoto = socketItemWithSubItem(flailHoto, createSocketableItem(r)).item
}
expect(swordHoto.flags?.runeword).toBe(false)
expect(flailHoto.flags?.runeword).toBe(true)
expect(flailHoto.name).toBe('Heart of the Oak')
expect(flailHoto.nameZh).toBe('橡树之心')
// 5. Magic base item with 4 sockets (Jeweler's Monarch) cannot form Spirit Runeword
let magicMonarch = createSocketedBaseItem('uit', 4, {
ilvl: 85,
quality: ItemQuality.MAGIC,
})
magicMonarch = {
...magicMonarch,
name: "Jeweler's Monarch",
rarity: 'magic',
}
for (const r of ['r07', 'r10', 'r09', 'r11']) {
magicMonarch = socketItemWithSubItem(magicMonarch, createSocketableItem(r)).item
}
expect(magicMonarch.flags?.runeword).toBe(false)
expect(magicMonarch.name).toBe("Jeweler's Monarch")
// Still receives Shield rune modifiers!
expect(magicMonarch.rolledProps?.some(p => p.code === 'res-cold' && p.value === 35)).toBe(true)
// 6. Superior base item WITH base rolledProps (+15% Enhanced Defense) forms Runeword AND retains base mods
let supMonarch = createSocketedBaseItem('uit', 4, {
ilvl: 85,
quality: ItemQuality.SUPERIOR,
baseRolledProps: [{ code: 'ac%', min: 15, max: 15, value: 15 }],
})
for (const r of ['r07', 'r10', 'r09', 'r11']) {
supMonarch = socketItemWithSubItem(supMonarch, createSocketableItem(r)).item
}
expect(supMonarch.flags?.runeword).toBe(true)
expect(supMonarch.name).toBe('Spirit')
expect(supMonarch.nameZh).toBe('精神')
expect(supMonarch.rolledProps?.some(p => p.code === 'ac%' && p.value === 15)).toBe(true)
})
it('supports Horadric Cube unsocketing, normal base socketing, and rune/gem upgrade recipes', () => {
const tables = getEmbeddedDropTables()
// 1. Unsocketing Recipe: 1x Hel ('r15') + 1x Scroll of Town Portal ('tsc') + Spirit Superior Monarch
let supMonarch = createSocketedBaseItem('uit', 4, {
ilvl: 85,
quality: ItemQuality.SUPERIOR,
baseRolledProps: [{ code: 'ac%', min: 15, max: 15, value: 15 }],
})
for (const r of ['r07', 'r10', 'r09', 'r11']) {
supMonarch = socketItemWithSubItem(supMonarch, createSocketableItem(r)).item
}
const spiritUi = { ...itemToUiInventoryItem(supMonarch, tables), id: 'spirit_monarch_1' }
const helUi = { ...itemToUiInventoryItem(createSocketableItem('r15'), tables), id: 'hel_1' }
const tscUi = { ...itemToUiInventoryItem(createSocketableItem('tsc'), tables), id: 'tsc_1' }
const unsocketRes = transmuteCubeItems([
{ item: spiritUi, col: 0, row: 0 },
{ item: helUi, col: 2, row: 0 },
{ item: tscUi, col: 2, row: 1 },
])
expect(unsocketRes.matched).toBe(true)
expect(unsocketRes.resultItems.length).toBe(1)
const restoredUi = unsocketRes.resultItems[0]!.item
expect(restoredUi.quality).toBe('normal')
expect(restoredUi.runewordRunes).toBeUndefined()
expect(getTotalSocketCount(restoredUi)).toBe(4)
expect(getFilledSocketCount(restoredUi)).toBe(0)
expect(restoredUi.rawItem?.flags?.runeword).toBe(false)
expect(restoredUi.rawItem?.rolledProps?.some(p => p.code === 'ac%' && p.value === 15)).toBe(true)
// 2. Normal Base Socketing Recipe: Tal + Thul + Perfect Topaz + Unsocketed Archon Plate -> 1..4 sockets
const unsocketedAp = { ...itemToUiInventoryItem(createSocketedBaseItem('utp', 0, { ilvl: 85 }), tables), id: 'clean_ap' }
const talUi = { ...itemToUiInventoryItem(createSocketableItem('r07'), tables), id: 'tal_1' }
const thulUi = { ...itemToUiInventoryItem(createSocketableItem('r10'), tables), id: 'thul_1' }
const ptopazUi = { ...itemToUiInventoryItem(createSocketableItem('gpy'), tables), id: 'gpy_1' }
const socketArmorRes = transmuteCubeItems(
[
{ item: unsocketedAp, col: 0, row: 0 },
{ item: talUi, col: 2, row: 0 },
{ item: thulUi, col: 2, row: 1 },
{ item: ptopazUi, col: 2, row: 2 },
],
new Rng(12345),
)
expect(socketArmorRes.matched).toBe(true)
expect(socketArmorRes.resultItems.length).toBe(1)
const socketedAp = socketArmorRes.resultItems[0]!.item
expect(socketedAp.sockets).toBeGreaterThanOrEqual(1)
expect(socketedAp.sockets).toBeLessThanOrEqual(4)
// 3. Rejects Superior item in Normal Base Socketing Recipe
const supUnsocketedAp = {
...itemToUiInventoryItem(
createSocketedBaseItem('utp', 0, { ilvl: 85, quality: ItemQuality.SUPERIOR }),
tables,
),
id: 'sup_ap',
}
const rejectSupRes = transmuteCubeItems([
{ item: supUnsocketedAp, col: 0, row: 0 },
{ item: talUi, col: 2, row: 0 },
{ item: thulUi, col: 2, row: 1 },
{ item: ptopazUi, col: 2, row: 2 },
])
expect(rejectSupRes.matched).toBe(false)
// 4. Rune Upgrade: 3x El ('r01') -> 1x Eld ('r02')
const el1 = { ...itemToUiInventoryItem(createSocketableItem('r01'), tables), id: 'el_1' }
const el2 = { ...itemToUiInventoryItem(createSocketableItem('r01'), tables), id: 'el_2' }
const el3 = { ...itemToUiInventoryItem(createSocketableItem('r01'), tables), id: 'el_3' }
const runeUpRes = transmuteCubeItems([
{ item: el1, col: 0, row: 0 },
{ item: el2, col: 1, row: 0 },
{ item: el3, col: 2, row: 0 },
])
expect(runeUpRes.matched).toBe(true)
expect(runeUpRes.resultItems[0]?.item.code).toBe('r02')
// 5. High Rune Upgrade with Gem: 2x Ber ('r30') + 1x Flawless Sapphire ('glb') -> 1x Jah ('r31')
const ber1 = { ...itemToUiInventoryItem(createSocketableItem('r30'), tables), id: 'ber_1' }
const ber2 = { ...itemToUiInventoryItem(createSocketableItem('r30'), tables), id: 'ber_2' }
const glb = { ...itemToUiInventoryItem(createSocketableItem('glb'), tables), id: 'glb_1' }
const highRuneUpRes = transmuteCubeItems([
{ item: ber1, col: 0, row: 0 },
{ item: ber2, col: 1, row: 0 },
{ item: glb, col: 2, row: 0 },
])
expect(highRuneUpRes.matched).toBe(true)
expect(highRuneUpRes.resultItems[0]?.item.code).toBe('r31')
// 6. Gem Upgrade: 3x Flawless Amethyst ('gzv') -> 1x Perfect Amethyst ('gpv')
const gzv1 = { ...itemToUiInventoryItem(createSocketableItem('gzv'), tables), id: 'gzv_1' }
const gzv2 = { ...itemToUiInventoryItem(createSocketableItem('gzv'), tables), id: 'gzv_2' }
const gzv3 = { ...itemToUiInventoryItem(createSocketableItem('gzv'), tables), id: 'gzv_3' }
const gemUpRes = transmuteCubeItems([
{ item: gzv1, col: 0, row: 0 },
{ item: gzv2, col: 1, row: 0 },
{ item: gzv3, col: 2, row: 0 },
])
expect(gemUpRes.matched).toBe(true)
expect(gemUpRes.resultItems[0]?.item.code).toBe('gpv')
})
it('supports interactive UI click-to-socket across Inventory, Equipment, Stash, and Horadric Cube', () => {
const tables = getEmbeddedDropTables()
const inv = new InventoryPanel()
const worldPanels = new WorldPanelsHud()
// 1. Place a 4-socket Crystal Sword in Inventory (col=0, row=0)
const crystalSwordUi = { ...itemToUiInventoryItem(createSocketedBaseItem('crs', 4, { ilvl: 80 }), tables), id: 'inv_crs' }
inv.gridItems = [{ item: crystalSwordUi, col: 0, row: 0 }]
// Click with Tal, Thul, Ort, Amn on cursor onto cell (0, 0)
for (const code of ['r07', 'r10', 'r09', 'r11']) {
inv.cursorItem = { ...itemToUiInventoryItem(createSocketableItem(code), tables), id: `cursor_${code}` }
expect(inv.clickGridCell(0, 0)).toBe(true)
expect(inv.cursorItem).toBeNull() // Rune consumed into socket!
}
const completedSword = inv.gridItems.find(p => p.col === 0 && p.row === 0)?.item
expect(completedSword).toBeDefined()
expect(completedSword?.quality).toBe('rune')
expect(completedSword?.runewordRunes).toBe("'TalThulOrtAmn'")
expect(completedSword?.rawItem?.flags?.runeword).toBe(true)
// 1b. Equip a 2-socket Bone Shield in weapon2 slot and socket Rhyme (Shael + Eth) via clickEquipSlot
inv.equipped.weapon2 = { ...itemToUiInventoryItem(createSocketedBaseItem('bsh', 2, { ilvl: 80 }), tables), id: 'eq_bsh' }
for (const code of ['r13', 'r05']) {
inv.cursorItem = { ...itemToUiInventoryItem(createSocketableItem(code), tables), id: `eq_cursor_${code}` }
expect(inv.clickEquipSlot('weapon2')).toBe(true)
expect(inv.cursorItem).toBeNull()
}
expect(inv.equipped.weapon2?.quality).toBe('rune')
expect(inv.equipped.weapon2?.runewordRunes).toBe("'ShaelEth'")
expect(inv.equipped.weapon2?.rawItem?.name).toBe('Rhyme')
expect(inv.equipped.weapon2?.rawItem?.nameZh).toBe('押韵')
// 2. Place a 2-socket Mage Plate in Stash (col=0, row=0) and socket Stealth (Tal + Eth)
worldPanels.stashItems = []
const magePlateUi = { ...itemToUiInventoryItem(createSocketedBaseItem('xtp', 2, { ilvl: 80 }), tables), id: 'stash_xtp' }
worldPanels.stashItems.push({ item: magePlateUi, col: 0, row: 0 })
for (const code of ['r07', 'r05']) {
const cursorRune = { ...itemToUiInventoryItem(createSocketableItem(code), tables), id: `stash_cursor_${code}` }
const stashClickRes = worldPanels.clickStashCell(0, 0, cursorRune)
expect(stashClickRes).toEqual({ handled: true, newCursorItem: null })
}
const stealthArmor = worldPanels.stashItems[0]?.item
expect(stealthArmor?.quality).toBe('rune')
expect(stealthArmor?.runewordRunes).toBe("'TalEth'")
expect(stealthArmor?.rawItem?.name).toBe('Stealth')
expect(stealthArmor?.rawItem?.nameZh).toBe('隐秘')
// 3. Place Stealth Mage Plate + Hel + Scroll of Town Portal in Cube and call worldPanels.transmuteCube()
worldPanels.cubeItems = [
{ item: stealthArmor!, col: 0, row: 0 },
{ item: { ...itemToUiInventoryItem(createSocketableItem('r15'), tables), id: 'cube_hel' }, col: 2, row: 0 },
{ item: { ...itemToUiInventoryItem(createSocketableItem('tsc'), tables), id: 'cube_tsc' }, col: 2, row: 1 },
]
expect(worldPanels.transmuteCube().matched).toBe(true)
expect(worldPanels.cubeItems.length).toBe(1)
expect(worldPanels.cubeItems[0]?.item.quality).toBe('normal')
expect(getFilledSocketCount(worldPanels.cubeItems[0]?.item)).toBe(0)
expect(getTotalSocketCount(worldPanels.cubeItems[0]?.item)).toBe(2)
})
})