feat(runeword): implement authentic 1.13c Runeword, socketing & Horadric Cube transmutation system
This commit is contained in:
parent
1d7445a64e
commit
fc1085418c
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}'`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue