diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 90ef786..c98de4a 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -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, diff --git a/src/game/gems.ts b/src/game/gems.ts index 0b970e0..e645fa7 100644 --- a/src/game/gems.ts +++ b/src/game/gems.ts @@ -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 } } diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index 0a95485..7cb4a5d 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -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}'` } } diff --git a/src/game/runeword-socket.ts b/src/game/runeword-socket.ts new file mode 100644 index 0000000..c58f10e --- /dev/null +++ b/src/game/runeword-socket.ts @@ -0,0 +1,1250 @@ +/** + * Diablo II v1.13c Runeword & Socketing Engine (`D2Common.dll` / `D2Game.dll`). + * + * Implements: + * 1. `resolveGemApplyType`: Authentic `ITEMS_GetGemApplyType` dispatch + * (`0 = weapon`, `1 = helm/torso`, `2 = shield/paladin shield/necromancer head`). + * 2. `canSocketItem` & `socketItemWithSubItem` / `socketUiInventoryItem`: + * - Validates target sockets, identification, equipment category, and sub-item socketability + * (33 Runes `r01..r33`, 35 Gems `gcv..skz`, and Jewels `jew`). + * - Applies slot-specific `GemModifier`s from `CANONICAL_GEMS_TABLE` (`Gems.txt`) or Jewel `rolledProps`. + * - Evaluates `checkRunewordMatch` (`Runes.txt`) when the final socket is filled on a + * Low / Normal / Superior base item where all socketed items are Runes. + * - Rolls `T1Code1..7` within `[T1Min, T1Max]` using deterministic `Rng`, sets `ItemFlag.RUNEWORD`, + * combines `(baseProps + socketedMods + runewordProps)`, and updates tooltip & `UiInventoryItem`. + * 3. `transmuteCubeItems`: + * - 1.13c Unsocketing Recipe: `1x Hel Rune (r15) + 1x Scroll of Town Portal (tsc) + 1x Socketed Item` + * -> destroys socketed items + reagents, restores clean socketed base item. + * - 1.13c Normal Base Socketing Recipes (`CubeMain.txt`): + * - `Ral + Amn + Perfect Amethyst + Normal Weapon` -> 1..6 sockets (clamped to `getMaxSockets`) + * - `Tal + Thul + Perfect Topaz + Normal Body Armor` -> 1..4 sockets (with 1.13c ethereal armor defense bonus) + * - `Ral + Thul + Perfect Sapphire + Normal Helm` -> 1..3 sockets + * - `Tal + Amn + Perfect Ruby + Normal Shield` -> 1..4 sockets + * - 1.13c Rune & Gem Upgrade Recipes (`CubeMain.txt`). + */ + +import { + ItemFlag, + ItemMode, + ItemQuality, + type Item, + type ItemBase, + type RolledItemProp, +} from './items.ts' +import { Rng } from './rng.ts' +import { + CANONICAL_GEMS_TABLE, + getMaxSockets, + type GemModifier, + type GemTable, +} from './gems.ts' +import { + GEM_INFO, + RUNE_INFO, + isGemCode, + isRuneCode, +} from './gems-data.ts' +import { + CANONICAL_RUNES_TABLE, + checkRunewordMatch, + getBaseItemSocketCount, + getItemTypeCodes, + isNormalQuality, + type RunewordMatchResult, + type RunewordTable, +} from './runeword.ts' +import { getEmbeddedDropTables } from './embedded-drop-tables.ts' +import { NON_RANDOM_RANGE_CODES } from './affix-generator.ts' +import { CANONICAL_ENG_TBL_DICT } from '../data/canonical-tbl-strings.ts' +import { translateItemName, resolveBaseItemNameEn } from './tooltip-i18n.ts' +import { itemToUiInventoryItem } from '../ui/item-bridge.ts' +import type { GridPlacement, UiInventoryItem } from '../ui/inventory.ts' + +/** + * `D2Common.dll` `ITEMS_GetGemApplyType`: + * - `0`: Weapon (`weap`, `mele`, `bow`, `miss`, `rod`, `swor`, `axe`, `pole`, `staf`, `wand`, `scep`, `orb`, `h2h`, etc.) + * - `1`: Helm or Body Armor (`helm`, `tors`, `phlm`, `pelt`, `circ`) + * - `2`: Shield (`shld`, `shie`, `ashd`, `head`) + * - `-1`: Not a valid socketable equipment target + */ +export type GemApplyType = 0 | 1 | 2 | -1 + +/** + * Resolves the `GemApplyType` (`0 = weapon`, `1 = helm/torso`, `2 = shield`) for an `Item` or `ItemBase`. + */ +export function resolveGemApplyType(itemOrBase: Item | ItemBase | UiInventoryItem): GemApplyType { + const tables = getEmbeddedDropTables() + const rawItem: Item | undefined = + 'rawItem' in itemOrBase && itemOrBase.rawItem + ? itemOrBase.rawItem + : 'base' in itemOrBase + ? (itemOrBase as Item) + : undefined + + const base: ItemBase | undefined = + rawItem?.base ?? + ('kind' in itemOrBase && 'invWidth' in itemOrBase + ? (itemOrBase as ItemBase) + : 'code' in itemOrBase && typeof itemOrBase.code === 'string' + ? tables.getBase(itemOrBase.code.trim()) + : undefined) + + const codes = new Set() + if (rawItem) { + for (const c of getItemTypeCodes(rawItem)) { + codes.add(c) + } + } + if (base) { + if (base.type) codes.add(base.type.trim().toLowerCase()) + if (base.type2) codes.add(base.type2.trim().toLowerCase()) + if (Array.isArray(base.tags)) { + for (const t of base.tags) { + if (typeof t === 'string' && t.trim()) codes.add(t.trim().toLowerCase()) + } + } + const lookedUp = tables.getBase(base.id.trim()) + if (lookedUp?.type) codes.add(lookedUp.type.trim().toLowerCase()) + } + if ('code' in itemOrBase && typeof itemOrBase.code === 'string') { + const lookedUp = tables.getBase(itemOrBase.code.trim()) + if (lookedUp?.type) codes.add(lookedUp.type.trim().toLowerCase()) + } + + // Check Shield first (since in D2 Armor.txt shields have kind='armor' and tag 'armo') + for (const code of codes) { + if ( + code === 'shld' || + code === 'shie' || + code === 'shield' || + code === 'ashd' || + code === 'head' || + tables.isA(code, 'shld') || + tables.isA(code, 'shie') || + tables.isA(code, 'ashd') || + tables.isA(code, 'head') + ) { + return 2 + } + } + + // Check Weapon + for (const code of codes) { + if ( + code === 'weap' || + code === 'weapon' || + code === 'mele' || + code === 'swor' || + code === 'bow' || + code === 'miss' || + code === 'rod' || + tables.isA(code, 'weap') || + tables.isA(code, 'mele') || + tables.isA(code, 'bow') || + tables.isA(code, 'miss') || + tables.isA(code, 'rod') + ) { + return 0 + } + } + + // Check Helm or Body Armor (Torso) + for (const code of codes) { + if ( + code === 'helm' || + code === 'helmet' || + code === 'tors' || + code === 'torso' || + code === 'body' || + code === 'phlm' || + code === 'pelt' || + code === 'circ' || + tables.isA(code, 'helm') || + tables.isA(code, 'tors') || + tables.isA(code, 'phlm') || + tables.isA(code, 'pelt') || + tables.isA(code, 'circ') + ) { + return 1 + } + } + + if (base?.kind === 'weapon') return 0 + if (base?.kind === 'armor') { + if (base.tags?.some(t => t === 'shld' || t === 'shie' || t === 'ashd' || t === 'head')) { + return 2 + } + return 1 + } + + return -1 +} + +/** + * Extracts the 3-letter item code from an `Item` or `UiInventoryItem`. + */ +export function extractItemCode(item: Item | UiInventoryItem | undefined | null): string { + if (!item) return '' + if ('rawItem' in item && item.rawItem) { + const raw = item.rawItem + return (raw.code || (raw.base as any)?.code || raw.base?.id || item.code || '').trim().toLowerCase() + } + if ('base' in item && item.base) { + return (item.code || (item.base as any)?.code || item.base.id || '').trim().toLowerCase() + } + if ('code' in item && typeof item.code === 'string') { + return item.code.trim().toLowerCase() + } + return '' +} + +/** + * Checks whether `subItem` is a valid socketable filler (Rune `r01..r33`, Gem `gcv..skz`, or Jewel `jew`). + */ +export function isSocketableFiller(subItem: Item | UiInventoryItem | undefined | null): boolean { + if (!subItem) return false + const code = extractItemCode(subItem) + if (!code) return false + if (isRuneCode(code) || isGemCode(code) || Boolean(CANONICAL_GEMS_TABLE.get(code))) { + return true + } + if (code === 'jew' || code === 'jewl') { + return true + } + const raw: Item | undefined = 'rawItem' in subItem ? subItem.rawItem : ('base' in subItem ? subItem : undefined) + if (raw?.base?.type === 'jewl' || raw?.base?.tags?.includes('jewl')) { + return true + } + return false +} + +/** + * Returns the number of currently filled sockets on an `Item` or `UiInventoryItem`. + */ +export function getFilledSocketCount(item: Item | UiInventoryItem | undefined | null): number { + if (!item) return 0 + const raw: Item | undefined = 'rawItem' in item ? item.rawItem : ('base' in item ? item : undefined) + if (raw?.socketedItems) { + return raw.socketedItems.length + } + if (typeof raw?.socketedCount === 'number') { + return raw.socketedCount + } + return 0 +} + +/** + * Returns the total socket capacity of an `Item` or `UiInventoryItem`. + */ +export function getTotalSocketCount(item: Item | UiInventoryItem | undefined | null): number { + if (!item) return 0 + const raw: Item | undefined = 'rawItem' in item ? item.rawItem : ('base' in item ? item : undefined) + if (raw) { + const count = getBaseItemSocketCount(raw) + if (count > 0) return count + } + if ('sockets' in item && typeof item.sockets === 'number' && item.sockets > 0) { + return item.sockets + } + return 0 +} + +/** + * Validates whether `subItem` (Rune, Gem, or Jewel) can be inserted into `targetItem` per 1.13c `D2Game.dll`. + */ +export function canSocketItem( + targetItem: Item | UiInventoryItem | undefined | null, + subItem: Item | UiInventoryItem | undefined | null, +): boolean { + if (!targetItem || !subItem) return false + if (!isSocketableFiller(subItem)) return false + + const totalSockets = getTotalSocketCount(targetItem) + if (totalSockets <= 0) return false + + const filledSockets = getFilledSocketCount(targetItem) + if (filledSockets >= totalSockets) return false + + // Target must be identified + const rawTarget: Item | undefined = + 'rawItem' in targetItem ? targetItem.rawItem : ('base' in targetItem ? targetItem : undefined) + if ('identified' in targetItem && targetItem.identified === false) return false + if (rawTarget) { + if (rawTarget.identified === false) return false + if (rawTarget.flags && rawTarget.flags.identified === false) return false + } + + // Target must be a valid equipment category (weapon, helm/torso, or shield) + const applyType = resolveGemApplyType(targetItem) + if (applyType === -1) return false + + // Sub-item if Jewel must also be identified + if ('identified' in subItem && subItem.identified === false) return false + const rawSub: Item | undefined = + 'rawItem' in subItem ? subItem.rawItem : ('base' in subItem ? subItem : undefined) + if (rawSub && rawSub.identified === false) return false + + return true +} + +/** + * Converts a `GemModifier` from `Gems.txt` into a concrete `RolledItemProp`. + */ +export function gemModifierToRolledProp(mod: GemModifier): RolledItemProp { + const code = mod.code.trim() + return { + code, + ...(mod.param !== undefined ? { param: mod.param } : {}), + min: mod.min, + max: mod.max, + value: mod.min, + } +} + +/** + * Resolves the concrete `RolledItemProp[]` contributed by a single socketed sub-item + * when inserted into `targetItem` (`GemApplyType` `0`, `1`, or `2`). + */ +export function getSocketedSubItemProps( + targetItem: Item | ItemBase | UiInventoryItem, + subItem: Item | UiInventoryItem, + gemTable: GemTable = CANONICAL_GEMS_TABLE, +): RolledItemProp[] { + const applyType = resolveGemApplyType(targetItem) + if (applyType === -1) return [] + + const subCode = extractItemCode(subItem) + const gemDef = gemTable.get(subCode) + if (gemDef) { + const mods = + applyType === 0 + ? gemDef.weaponMods + : applyType === 1 + ? gemDef.helmMods + : gemDef.shieldMods + return mods.map(gemModifierToRolledProp) + } + + // Jewel ('jew'): copy its rolledProps + const rawSub: Item | undefined = + 'rawItem' in subItem ? subItem.rawItem : ('base' in subItem ? subItem : undefined) + if (rawSub?.rolledProps && rawSub.rolledProps.length > 0) { + return rawSub.rolledProps.map(p => ({ ...p })) + } + return [] +} + +/** + * Ensures a socketable `UiInventoryItem` or `Item` is represented as a valid `Item` instance + * for storage in `target.socketedItems`. + */ +export function toSocketableRawItem(subItem: Item | UiInventoryItem): Item { + if ('base' in subItem && subItem.base) { + return { + ...subItem, + location: { mode: ItemMode.SOCKETED }, + } + } + if ('rawItem' in subItem && subItem.rawItem) { + const raw = subItem.rawItem + return { + ...raw, + ...(subItem.code || raw.code ? { code: (subItem.code || raw.code)! } : {}), + location: { mode: ItemMode.SOCKETED }, + } + } + const code = (subItem.code ?? '').trim().toLowerCase() + const tables = getEmbeddedDropTables() + const baseFromTable = tables.getBase(code) + const runeInfo = RUNE_INFO[code] + const gemInfo = GEM_INFO[code] + const nameEn = runeInfo?.nameEn ?? gemInfo?.nameEn ?? subItem.name ?? code + const nameZh = runeInfo ? `${runeInfo.nameZh}符文` : (gemInfo?.nameZh ?? subItem.nameZh ?? nameEn) + const subReqLevel = 'reqLevel' in subItem ? subItem.reqLevel : undefined + const reqLevel = runeInfo?.reqLevel ?? gemInfo?.reqLevel ?? subReqLevel ?? 1 + + const base: ItemBase = baseFromTable ?? { + id: code, + name: nameEn, + kind: 'misc', + invWidth: 1, + invHeight: 1, + maxStack: 1, + value: 100, + damage: 0, + defense: 0, + tags: [isRuneCode(code) ? 'rune' : isGemCode(code) ? 'gem' : 'jewl'], + level: reqLevel, + type: isRuneCode(code) ? 'rune' : isGemCode(code) ? 'gem' : 'jewl', + } + + return { + base, + prefix: null, + suffix: null, + level: reqLevel, + ilvl: reqLevel, + name: nameEn, + nameZh, + stats: {}, + invWidth: 1, + invHeight: 1, + stack: 1, + value: base.value, + code, + quality: ItemQuality.NORMAL, + rarity: isRuneCode(code) ? 'rune' : 'normal', + identified: true, + location: { mode: ItemMode.SOCKETED }, + } +} + +export interface SocketOperationResult { + readonly success: boolean + readonly item: Item + readonly uiItem: UiInventoryItem + readonly runewordMatch?: RunewordMatchResult | undefined + readonly reason?: string | undefined +} + +/** + * Recalculates an item's effective `defense` and `stats` record from its `base` and `rolledProps`. + */ +function recomputeItemStats( + baseItem: Item, + rolledProps: readonly RolledItemProp[], +): { + readonly defense: number | undefined + readonly stats: Record +} { + let totalEdPct = 0 + let flatMinDmg = 0 + let flatMaxDmg = 0 + let totalAcPct = 0 + let flatAc = 0 + let indestruct = 0 + + for (const p of rolledProps) { + const c = (p.code || '').trim().toLowerCase() + if (c === 'dmg%') totalEdPct += p.value + else if (c === 'dmg-min') flatMinDmg += p.value + else if (c === 'dmg-max') flatMaxDmg += p.value + else if (c === 'dmg-norm') { + flatMinDmg += p.min > 0 ? p.min : p.value + flatMaxDmg += p.max > 0 ? p.max : p.value + } else if (c === 'dmg') { + flatMinDmg += p.value + flatMaxDmg += p.value + } else if (c === 'ac%') totalAcPct += p.value + else if (c === 'ac') flatAc += p.value + else if (c === 'indestruct') indestruct = 1 + } + + const isEth = Boolean( + baseItem.flags?.ethereal || + (baseItem as any).ethereal || + baseItem.stats?.ethereal, + ) + const isLow = baseItem.quality === ItemQuality.LOW || baseItem.rarity === 'low' + + const rawBaseDef = baseItem.base.defense > 0 ? baseItem.base.defense : 0 + let finalDef: number | undefined = baseItem.defense + if (rawBaseDef > 0) { + let effectiveBaseDef = rawBaseDef + if (isLow) { + effectiveBaseDef = Math.max(1, Math.floor(effectiveBaseDef * 0.75)) + } + if (isEth) { + effectiveBaseDef = Math.floor(effectiveBaseDef * 1.5) + } + // If the item had ebugged base defense stored in base.defense, it's already in rawBaseDef + let computed = Math.floor((effectiveBaseDef * (100 + totalAcPct)) / 100) + flatAc + if (totalAcPct > 0 && computed === effectiveBaseDef) { + computed = effectiveBaseDef + 1 + } + finalDef = computed + } + + const nextStats: Record = { + ...baseItem.stats, + } + if (finalDef !== undefined) { + nextStats.defense = finalDef + } + if (baseItem.base.damage > 0) { + let effDmg = baseItem.base.damage + if (isLow) effDmg = Math.max(1, Math.floor(effDmg * 0.75)) + if (isEth) effDmg = Math.floor(effDmg * 1.5) + nextStats.damage = Math.floor((effDmg * (100 + totalEdPct)) / 100) + Math.floor((flatMinDmg + flatMaxDmg) / 2) + } + if (indestruct) { + nextStats.indestruct = 1 + } + + return { defense: finalDef, stats: nextStats } +} + +/** + * Ensures a target `Item | UiInventoryItem` has a concrete underlying `Item` instance. + */ +function ensureTargetRawItem(targetInput: Item | UiInventoryItem): Item | undefined { + if ('rawItem' in targetInput && targetInput.rawItem) { + return targetInput.rawItem + } + if ('base' in targetInput && targetInput.base) { + return targetInput as Item + } + if ('code' in targetInput && typeof targetInput.code === 'string') { + const code = targetInput.code.trim() + const tables = getEmbeddedDropTables() + const base = tables.getBase(code) + if (!base) return undefined + const uiTarget = targetInput as UiInventoryItem + const sockets = uiTarget.sockets ?? 0 + const qualityMap: Record = { + normal: ItemQuality.NORMAL, + magic: ItemQuality.MAGIC, + rare: ItemQuality.RARE, + set: ItemQuality.SET, + unique: ItemQuality.UNIQUE, + craft: ItemQuality.CRAFTED, + rune: ItemQuality.NORMAL, + } + const q = qualityMap[String(uiTarget.quality ?? 'normal')] ?? ItemQuality.NORMAL + return createSocketedBaseItem(code, sockets, { + ilvl: Math.max(1, uiTarget.reqLevel ?? base.level ?? 25), + quality: q, + ethereal: Boolean(uiTarget.ethereal), + }) + } + return undefined +} + +function toUiItemWithId(item: Item, uiId?: string): UiInventoryItem { + const ui = itemToUiInventoryItem(item, getEmbeddedDropTables()) + return uiId ? { ...ui, id: uiId } : ui +} + +export function socketItemWithSubItem( + targetInput: Item | UiInventoryItem, + subInput: Item | UiInventoryItem, + rng: Rng = new Rng(Date.now() >>> 0), + options?: { + readonly runewordTable?: RunewordTable + readonly gemTable?: GemTable + readonly isLadder?: boolean + }, +): SocketOperationResult { + const rawTarget: Item | undefined = ensureTargetRawItem(targetInput) + + if (!rawTarget || !canSocketItem(targetInput, subInput)) { + const fallbackItem = rawTarget ?? toSocketableRawItem(targetInput) + return { + success: false, + item: fallbackItem, + uiItem: 'id' in targetInput && 'allowedSlots' in targetInput + ? targetInput + : toUiItemWithId(fallbackItem), + reason: 'Target item cannot accept this socketable item', + } + } + + const totalSockets = getTotalSocketCount(targetInput) + const existingSocketed = rawTarget.socketedItems ?? [] + + // Snapshot the clean unsocketed base item before the very first insertion + const unsocketedSnapshot: Item = + (rawTarget as any).unsocketedSnapshot ?? + ({ + ...rawTarget, + socketedItems: [], + socketedCount: 0, + runeword: undefined, + flags: { + ...rawTarget.flags, + socketed: true, + runeword: false, + }, + rawFlags: (((rawTarget.rawFlags ?? 0) | ItemFlag.SOCKETED) & ~ItemFlag.RUNEWORD) >>> 0, + } as Item) + + const baseRolledProps: readonly RolledItemProp[] = + (rawTarget as any).baseRolledProps ?? (rawTarget.rolledProps ? [...rawTarget.rolledProps] : []) + + const subRaw = toSocketableRawItem(subInput) + const nextSocketedItems: Item[] = [...existingSocketed, subRaw] + + const gemTable = options?.gemTable ?? CANONICAL_GEMS_TABLE + const socketedMods: RolledItemProp[] = [] + for (const sockItem of nextSocketedItems) { + socketedMods.push(...getSocketedSubItemProps(unsocketedSnapshot, sockItem, gemTable)) + } + + const tables = getEmbeddedDropTables() + const allRunes = nextSocketedItems.every(s => isRuneCode(extractItemCode(s))) + const runeCodes = nextSocketedItems.map(s => extractItemCode(s)) + + // Check for Runeword completion when the final socket is filled with Runes on a Normal/Superior/Low base + let runewordMatch: RunewordMatchResult | undefined + if ( + nextSocketedItems.length === totalSockets && + allRunes && + isNormalQuality(unsocketedSnapshot) + ) { + const baseForMatch: Item = { + ...unsocketedSnapshot, + totalSockets, + stats: { + ...unsocketedSnapshot.stats, + sockets: totalSockets, + }, + } + runewordMatch = checkRunewordMatch( + baseForMatch, + runeCodes, + tables.isA, + options?.runewordTable ?? CANONICAL_RUNES_TABLE, + { isLadder: options?.isLadder ?? true }, + ) + } + + const baseCode = (unsocketedSnapshot.code || (unsocketedSnapshot.base as any)?.code || unsocketedSnapshot.base.id || '').trim() + const baseNameEn = resolveBaseItemNameEn(unsocketedSnapshot.base.name, baseCode) + + if (runewordMatch) { + // Roll T1Code1..7 properties from the Runeword definition + const rolledRunewordProps: RolledItemProp[] = [] + for (const prop of runewordMatch.t1Codes) { + const code = prop.code.trim() + if (!code) continue + const isRangeCode = NON_RANDOM_RANGE_CODES.has(code.toLowerCase()) + let val: number + if (isRangeCode) { + val = prop.min + } else if (prop.min === prop.max) { + val = prop.min + } else { + const lo = Math.min(prop.min, prop.max) + const hi = Math.max(prop.min, prop.max) + val = rng.int(lo, hi) + } + rolledRunewordProps.push({ + code, + ...(prop.param !== undefined ? { param: prop.param } : {}), + min: prop.min, + max: prop.max, + value: val, + }) + } + + const combinedProps: RolledItemProp[] = [ + ...baseRolledProps, + ...socketedMods, + ...rolledRunewordProps, + ] + + const rwEn = (CANONICAL_ENG_TBL_DICT[runewordMatch.name] ?? runewordMatch.runeName).trim() + const rwZh = translateItemName(runewordMatch.name) || translateItemName(rwEn) || rwEn + const recomputed = recomputeItemStats(unsocketedSnapshot, combinedProps) + + const completedItem: Item = { + ...unsocketedSnapshot, + name: rwEn, + nameZh: rwZh, + rarity: 'runeword', + quality: unsocketedSnapshot.quality ?? ItemQuality.NORMAL, + totalSockets, + socketedCount: nextSocketedItems.length, + socketedItems: nextSocketedItems, + rolledProps: combinedProps, + defense: recomputed.defense, + stats: { + ...recomputed.stats, + sockets: totalSockets, + }, + flags: { + ...unsocketedSnapshot.flags, + identified: true, + socketed: true, + runeword: true, + }, + rawFlags: + (((unsocketedSnapshot.rawFlags ?? 0) | + ItemFlag.IDENTIFIED | + ItemFlag.SOCKETED | + ItemFlag.RUNEWORD) >>> + 0), + runeword: { + id: runewordMatch.runewordId, + ...( { + name: runewordMatch.name, + runeName: runewordMatch.runeName, + runes: runewordMatch.runes, + t1Codes: runewordMatch.t1Codes, + } as any ), + }, + ...({ + unsocketedSnapshot, + baseRolledProps, + } as any), + } + + const uiId = 'id' in targetInput ? targetInput.id : undefined + const uiItem = toUiItemWithId(completedItem, uiId) + return { + success: true, + item: completedItem, + uiItem, + runewordMatch, + } + } + + // Partial or non-Runeword socketed item ("Gemmed " for normal/superior/low quality) + const combinedProps: RolledItemProp[] = [...baseRolledProps, ...socketedMods] + const recomputed = recomputeItemStats(unsocketedSnapshot, combinedProps) + const isNormalBase = isNormalQuality(unsocketedSnapshot) + const gemmedNameEn = isNormalBase ? `Gemmed ${baseNameEn}` : unsocketedSnapshot.name + const gemmedNameZh = isNormalBase ? translateItemName(gemmedNameEn) : unsocketedSnapshot.nameZh + + const updatedItem: Item = { + ...unsocketedSnapshot, + name: gemmedNameEn, + ...(gemmedNameZh ? { nameZh: gemmedNameZh } : {}), + totalSockets, + socketedCount: nextSocketedItems.length, + socketedItems: nextSocketedItems, + rolledProps: combinedProps, + defense: recomputed.defense, + stats: { + ...recomputed.stats, + sockets: totalSockets, + }, + flags: { + ...unsocketedSnapshot.flags, + identified: true, + socketed: true, + runeword: false, + }, + rawFlags: + ((((unsocketedSnapshot.rawFlags ?? 0) | ItemFlag.IDENTIFIED | ItemFlag.SOCKETED) & + ~ItemFlag.RUNEWORD) >>> + 0), + runeword: undefined, + ...({ + unsocketedSnapshot, + baseRolledProps, + } as any), + } + + const uiId = 'id' in targetInput ? targetInput.id : undefined + const uiItem = toUiItemWithId(updatedItem, uiId) + return { + success: true, + item: updatedItem, + uiItem, + } +} + +/** + * Creates a canonical 1.13c socketed (or unsocketed) base equipment `Item` from a 3-letter item code + * (e.g. `'crs'` Crystal Sword, `'mon'` Monarch, `'xtp'` Mage Plate, `'crn'` Crown, `'bsd'` Broad Sword, `'voul'`/ `'vou'` Voulge). + */ +export function createSocketedBaseItem( + code: string, + sockets: number, + options?: { + readonly ilvl?: number | undefined + readonly quality?: ItemQuality | undefined + readonly ethereal?: boolean | undefined + readonly baseRolledProps?: readonly RolledItemProp[] | undefined + }, +): Item { + const tables = getEmbeddedDropTables() + const cleanCode = code.trim() + const base = tables.getBase(cleanCode) + if (!base) { + throw new Error(`Unknown 1.13c base item code: ${cleanCode}`) + } + + const ilvl = options?.ilvl ?? Math.max(1, base.level || 25) + const quality = options?.quality ?? ItemQuality.NORMAL + const isEth = Boolean(options?.ethereal) + const baseNameEn = resolveBaseItemNameEn(base.name, cleanCode) + const baseNameZh = translateItemName(baseNameEn) + const baseRolledProps = options?.baseRolledProps ? [...options.baseRolledProps] : [] + + let rawFlags = ItemFlag.IDENTIFIED + if (sockets > 0) rawFlags |= ItemFlag.SOCKETED + if (isEth) rawFlags |= ItemFlag.ETHEREAL + + let effDef = base.defense > 0 ? base.defense : undefined + if (effDef !== undefined && isEth) { + effDef = Math.floor(effDef * 1.5) + } + + const maxDur = Number((base as any).durability) || (base.kind === 'armor' ? 40 : base.kind === 'weapon' ? 30 : 0) + + const item: Item = { + base, + prefix: null, + suffix: null, + level: ilvl, + ilvl, + name: quality === ItemQuality.SUPERIOR ? `Superior ${baseNameEn}` : quality === ItemQuality.LOW ? `Cracked ${baseNameEn}` : baseNameEn, + nameZh: quality === ItemQuality.SUPERIOR ? `超强的${baseNameZh}` : quality === ItemQuality.LOW ? `碎裂的${baseNameZh}` : baseNameZh, + stats: { + ...(effDef !== undefined ? { defense: effDef } : {}), + ...(base.damage > 0 ? { damage: isEth ? Math.floor(base.damage * 1.5) : base.damage } : {}), + ...(sockets > 0 ? { sockets } : {}), + ...(isEth ? { ethereal: 1 } : {}), + }, + invWidth: base.invWidth, + invHeight: base.invHeight, + stack: 1, + value: base.cost ?? base.value ?? 100, + code: cleanCode, + quality, + rarity: quality === ItemQuality.SUPERIOR ? 'superior' : quality === ItemQuality.LOW ? 'low' : 'normal', + identified: true, + totalSockets: sockets > 0 ? sockets : undefined, + socketedCount: 0, + socketedItems: [], + defense: effDef, + durability: maxDur > 0 ? maxDur : undefined, + maxDurability: maxDur > 0 ? maxDur : undefined, + rolledProps: baseRolledProps, + flags: { + identified: true, + socketed: sockets > 0, + ethereal: isEth, + runeword: false, + }, + rawFlags: rawFlags >>> 0, + ...({ + baseRolledProps, + } as any), + } + + return item +} + +/** + * Creates a canonical 1.13c Rune or Gem `Item` from its 3-letter code (`r01..r33` or `gcv..skz`). + */ +export function createSocketableItem(code: string): Item { + const cleanCode = code.trim().toLowerCase() + const tables = getEmbeddedDropTables() + const baseFromTable = tables.getBase(cleanCode) + const runeInfo = RUNE_INFO[cleanCode] + const gemInfo = GEM_INFO[cleanCode] + if (!runeInfo && !gemInfo && !baseFromTable) { + throw new Error(`Unknown socketable item code: ${cleanCode}`) + } + + const nameEn = runeInfo?.nameEn ?? gemInfo?.nameEn ?? baseFromTable?.name ?? cleanCode + const nameZh = runeInfo ? `${runeInfo.nameZh}符文` : (gemInfo?.nameZh ?? translateItemName(nameEn)) + const reqLevel = runeInfo?.reqLevel ?? gemInfo?.reqLevel ?? baseFromTable?.level ?? 1 + + const base: ItemBase = baseFromTable ?? { + id: cleanCode, + name: nameEn, + kind: 'misc', + invWidth: 1, + invHeight: 1, + maxStack: 1, + value: 100, + damage: 0, + defense: 0, + tags: [runeInfo ? 'rune' : 'gem'], + level: reqLevel, + type: runeInfo ? 'rune' : 'gem', + } + + return { + base, + prefix: null, + suffix: null, + level: reqLevel, + ilvl: reqLevel, + name: nameEn, + nameZh, + stats: {}, + invWidth: 1, + invHeight: 1, + stack: 1, + value: base.cost ?? base.value ?? 100, + code: cleanCode, + quality: ItemQuality.NORMAL, + rarity: runeInfo ? 'rune' : 'normal', + identified: true, + flags: { + identified: true, + }, + rawFlags: ItemFlag.IDENTIFIED, + } +} + +export interface CubeTransmuteResult { + readonly matched: boolean + readonly recipeName?: string | undefined + readonly resultItems: readonly GridPlacement[] +} + +/** + * Canonical 1.13c Rune upgrade required gem map (`CubeMain.txt`): + * - `r01..r09`: 3 runes -> next rune (no gem required) + * - `r10..r20`: 3 runes + 1 chipped/flawed gem -> next rune + * - `r21..r32`: 2 runes + 1 flawed/normal/flawless gem -> next rune + */ +const RUNE_UPGRADE_RECIPES: Readonly> = { + 1: { count: 3 }, + 2: { count: 3 }, + 3: { count: 3 }, + 4: { count: 3 }, + 5: { count: 3 }, + 6: { count: 3 }, + 7: { count: 3 }, + 8: { count: 3 }, + 9: { count: 3 }, + 10: { count: 3, gemCode: 'gcy' }, // 3 Thul + Chipped Topaz -> Amn + 11: { count: 3, gemCode: 'gcv' }, // 3 Amn + Chipped Amethyst -> Sol + 12: { count: 3, gemCode: 'gcb' }, // 3 Sol + Chipped Sapphire -> Shael + 13: { count: 3, gemCode: 'gcr' }, // 3 Shael + Chipped Ruby -> Dol + 14: { count: 3, gemCode: 'gcg' }, // 3 Dol + Chipped Emerald -> Hel + 15: { count: 3, gemCode: 'gcw' }, // 3 Hel + Chipped Diamond -> Io + 16: { count: 3, gemCode: 'gfy' }, // 3 Io + Flawed Topaz -> Lum + 17: { count: 3, gemCode: 'gfv' }, // 3 Lum + Flawed Amethyst -> Ko + 18: { count: 3, gemCode: 'gfb' }, // 3 Ko + Flawed Sapphire -> Fal + 19: { count: 3, gemCode: 'gfr' }, // 3 Fal + Flawed Ruby -> Lem + 20: { count: 3, gemCode: 'gfg' }, // 3 Lem + Flawed Emerald -> Pul + 21: { count: 2, gemCode: 'gfw' }, // 2 Pul + Flawed Diamond -> Um + 22: { count: 2, gemCode: 'gsy' }, // 2 Um + Topaz -> Mal + 23: { count: 2, gemCode: 'gsv' }, // 2 Mal + Amethyst -> Ist + 24: { count: 2, gemCode: 'gsb' }, // 2 Ist + Sapphire -> Gul + 25: { count: 2, gemCode: 'gsr' }, // 2 Gul + Ruby -> Vex + 26: { count: 2, gemCode: 'gsg' }, // 2 Vex + Emerald -> Ohm + 27: { count: 2, gemCode: 'gsw' }, // 2 Ohm + Diamond -> Lo + 28: { count: 2, gemCode: 'gly' }, // 2 Lo + Flawless Topaz -> Sur + 29: { count: 2, gemCode: 'gzv' }, // 2 Sur + Flawless Amethyst -> Ber + 30: { count: 2, gemCode: 'glb' }, // 2 Ber + Flawless Sapphire -> Jah + 31: { count: 2, gemCode: 'glr' }, // 2 Jah + Flawless Ruby -> Cham + 32: { count: 2, gemCode: 'glg' }, // 2 Cham + Flawless Emerald -> Zod +} + +/** + * Canonical 1.13c Gem upgrade chains (3x same gem -> 1x next tier gem). + */ +const GEM_UPGRADE_CHAINS: readonly (readonly string[])[] = [ + ['gcv', 'gfv', 'gsv', 'gzv', 'gpv'], // Amethyst + ['gcy', 'gfy', 'gsy', 'gly', 'gpy'], // Topaz + ['gcb', 'gfb', 'gsb', 'glb', 'gpb'], // Sapphire + ['gcg', 'gfg', 'gsg', 'glg', 'gpg'], // Emerald + ['gcr', 'gfr', 'gsr', 'glr', 'gpr'], // Ruby + ['gcw', 'gfw', 'gsw', 'glw', 'gpw'], // Diamond + ['skc', 'skf', 'sku', 'skl', 'skz'], // Skull +] + +/** + * Restores a socketed item to its clean, unsocketed-fillers state (preserving its empty sockets) + * per 1.13c `1 Hel Rune + Scroll of Town Portal + Any Socketed Item`. + */ +export function unsocketEquipmentItem(targetUiItem: UiInventoryItem): UiInventoryItem { + const raw = targetUiItem.rawItem + if (!raw) return targetUiItem + + const totalSockets = getTotalSocketCount(targetUiItem) + const snapshot: Item | undefined = (raw as any).unsocketedSnapshot + if (snapshot) { + const restoredRaw: Item = { + ...snapshot, + totalSockets, + socketedCount: 0, + socketedItems: [], + runeword: undefined, + flags: { + ...snapshot.flags, + identified: true, + socketed: totalSockets > 0, + runeword: false, + }, + rawFlags: + ((((snapshot.rawFlags ?? 0) | ItemFlag.IDENTIFIED | (totalSockets > 0 ? ItemFlag.SOCKETED : 0)) & + ~ItemFlag.RUNEWORD) >>> + 0), + } + return toUiItemWithId(restoredRaw, targetUiItem.id) + } + + const baseRolledProps: readonly RolledItemProp[] = (raw as any).baseRolledProps ?? [] + const baseCode = (raw.code || (raw.base as any)?.code || raw.base.id || '').trim() + const baseNameEn = resolveBaseItemNameEn(raw.base.name, baseCode) + const baseNameZh = translateItemName(baseNameEn) + const recomputed = recomputeItemStats( + { + ...raw, + socketedItems: [], + socketedCount: 0, + runeword: undefined, + }, + baseRolledProps, + ) + + const wasRuneword = Boolean(raw.runeword || raw.flags?.runeword || raw.rarity === 'runeword') + const restoredQuality = raw.quality ?? ItemQuality.NORMAL + const restoredRarity = wasRuneword + ? restoredQuality === ItemQuality.SUPERIOR + ? 'superior' + : restoredQuality === ItemQuality.LOW + ? 'low' + : 'normal' + : raw.rarity ?? 'normal' + + const restoredRaw: Item = { + ...raw, + name: + restoredRarity === 'superior' + ? `Superior ${baseNameEn}` + : restoredRarity === 'low' + ? `Cracked ${baseNameEn}` + : wasRuneword || raw.name.startsWith('Gemmed ') + ? baseNameEn + : raw.name, + nameZh: + restoredRarity === 'superior' + ? `超强的${baseNameZh}` + : restoredRarity === 'low' + ? `碎裂的${baseNameZh}` + : wasRuneword || raw.name.startsWith('Gemmed ') + ? baseNameZh + : raw.nameZh, + rarity: restoredRarity, + totalSockets, + socketedCount: 0, + socketedItems: [], + rolledProps: baseRolledProps, + defense: recomputed.defense, + stats: { + ...recomputed.stats, + sockets: totalSockets, + }, + runeword: undefined, + flags: { + ...raw.flags, + identified: true, + socketed: totalSockets > 0, + runeword: false, + }, + rawFlags: + ((((raw.rawFlags ?? 0) | ItemFlag.IDENTIFIED | (totalSockets > 0 ? ItemFlag.SOCKETED : 0)) & + ~ItemFlag.RUNEWORD) >>> + 0), + } + + return toUiItemWithId(restoredRaw, targetUiItem.id) +} + +/** + * Evaluates and executes Horadric Cube recipes on the given `cubePlacements` (3x4 grid). + * + * Supported 1.13c recipes: + * 1. **Unsocket Equipment (`CubeMain.txt`)**: + * `1x Hel Rune (r15) + 1x Scroll of Town Portal (tsc) + 1x Socketed Equipment (with >= 1 socketed item)` + * -> Destroys Hel, TP scroll, and all socketed runes/gems/jewels; returns clean socketed base item. + * 2. **Normal Base Socketing Recipes (`CubeMain.txt`)**: + * - `Ral (r08) + Amn (r11) + Perfect Amethyst (gpv) + Normal Unsocketed Weapon` -> adds 1..6 sockets + * - `Tal (r07) + Thul (r10) + Perfect Topaz (gpy) + Normal Unsocketed Body Armor` -> adds 1..4 sockets + * - `Ral (r08) + Thul (r10) + Perfect Sapphire (gpb) + Normal Unsocketed Helm` -> adds 1..3 sockets + * - `Tal (r07) + Amn (r11) + Perfect Ruby (gpr) + Normal Unsocketed Shield` -> adds 1..4 sockets + * 3. **Rune Upgrades (`CubeMain.txt`)**: + * - `3x r01..r09` -> `1x next rune` + * - `3x r10..r20 (+ optional chipped/flawed gem)` -> `1x next rune` + * - `2x r21..r32 (+ optional gem)` -> `1x next rune` + * 4. **Gem Upgrades (`CubeMain.txt`)**: + * - `3x same gem (Chipped..Flawless)` -> `1x next quality gem` + */ +export function transmuteCubeItems( + cubePlacements: readonly GridPlacement[], + rng: Rng = new Rng(Date.now() >>> 0), +): CubeTransmuteResult { + if (cubePlacements.length === 0) { + return { matched: false, resultItems: cubePlacements } + } + + const items = cubePlacements.map(p => p.item) + const codes = items.map(it => extractItemCode(it)) + + // 1. Check Unsocketing Recipe: 1x Hel Rune ('r15') + 1x Scroll of Town Portal ('tsc') + 1x Socketed Equipment + if (items.length === 3) { + const helIdx = codes.findIndex(c => c === 'r15') + const tscIdx = codes.findIndex(c => c === 'tsc') + if (helIdx !== -1 && tscIdx !== -1) { + const equipIdx = [0, 1, 2].find(i => i !== helIdx && i !== tscIdx)! + const equipItem = items[equipIdx]! + if (getFilledSocketCount(equipItem) > 0) { + const cleaned = unsocketEquipmentItem(equipItem) + return { + matched: true, + recipeName: 'Unsocket Item (Hel + Scroll of Town Portal)', + resultItems: [{ item: cleaned, col: 0, row: 0 }], + } + } + } + } + + // 2. Check Normal Base Socketing Recipes (4 items: 2 runes + 1 perfect gem + 1 normal unsocketed equipment) + if (items.length === 4) { + const equipCandidates = items.filter(it => { + const c = extractItemCode(it) + return !isRuneCode(c) && !isGemCode(c) && c !== 'tsc' && c !== 'isc' && c !== 'jew' + }) + if (equipCandidates.length === 1) { + const equipUi = equipCandidates[0]! + const rawEquip = equipUi.rawItem + const existingSockets = getTotalSocketCount(equipUi) + const isStrictNormal = + equipUi.quality === 'normal' && + (!rawEquip || + ((rawEquip.quality === undefined || rawEquip.quality === ItemQuality.NORMAL) && + rawEquip.rarity !== 'superior' && + rawEquip.rarity !== 'low' && + !equipUi.name.startsWith('Superior ') && + !equipUi.name.startsWith('Cracked '))) + + if (existingSockets === 0 && isStrictNormal) { + const otherCodes = items + .filter(it => it !== equipUi) + .map(it => extractItemCode(it)) + .sort() + const applyType = resolveGemApplyType(equipUi) + const tables = getEmbeddedDropTables() + const eqCode = extractItemCode(equipUi) + const baseDef = rawEquip?.base ?? tables.getBase(eqCode) + const typeCode = (baseDef?.type || '').trim().toLowerCase() + const itemTypeDef = tables.itemTypes.byCode.get(typeCode) + const maxBaseSockets = Number((baseDef as any)?.gemsockets ?? (baseDef as any)?.gemSockets ?? 0) + const ilvl = rawEquip?.ilvl ?? rawEquip?.level ?? baseDef?.level ?? 40 + const maxAllowedSockets = itemTypeDef + ? getMaxSockets(maxBaseSockets, itemTypeDef, ilvl) + : maxBaseSockets + + if (maxAllowedSockets > 0) { + const isHelm = + applyType === 1 && + (typeCode === 'helm' || + typeCode === 'phlm' || + typeCode === 'pelt' || + typeCode === 'circ' || + tables.isA(typeCode, 'helm')) + const isTorso = + applyType === 1 && !isHelm && (typeCode === 'tors' || tables.isA(typeCode, 'tors')) + + let rollCap = 0 + let recipeLabel = '' + // Weapon: Ral (r08) + Amn (r11) + Perfect Amethyst (gpv) -> ['gpv', 'r08', 'r11'] + if ( + applyType === 0 && + otherCodes[0] === 'gpv' && + otherCodes[1] === 'r08' && + otherCodes[2] === 'r11' + ) { + rollCap = 6 + recipeLabel = 'Socket Normal Weapon (Ral + Amn + Perfect Amethyst)' + } + // Body Armor: Tal (r07) + Thul (r10) + Perfect Topaz (gpy) -> ['gpy', 'r07', 'r10'] + else if ( + isTorso && + otherCodes[0] === 'gpy' && + otherCodes[1] === 'r07' && + otherCodes[2] === 'r10' + ) { + rollCap = 4 + recipeLabel = 'Socket Normal Body Armor (Tal + Thul + Perfect Topaz)' + } + // Helm: Ral (r08) + Thul (r10) + Perfect Sapphire (gpb) -> ['gpb', 'r08', 'r10'] + else if ( + isHelm && + otherCodes[0] === 'gpb' && + otherCodes[1] === 'r08' && + otherCodes[2] === 'r10' + ) { + rollCap = 3 + recipeLabel = 'Socket Normal Helm (Ral + Thul + Perfect Sapphire)' + } + // Shield: Tal (r07) + Amn (r11) + Perfect Ruby (gpr) -> ['gpr', 'r07', 'r11'] + else if ( + applyType === 2 && + otherCodes[0] === 'gpr' && + otherCodes[1] === 'r07' && + otherCodes[2] === 'r11' + ) { + rollCap = 4 + recipeLabel = 'Socket Normal Shield (Tal + Amn + Perfect Ruby)' + } + + if (rollCap > 0 && baseDef) { + const rolled = rng.int(1, rollCap) + const finalSockets = Math.max(1, Math.min(rolled, maxAllowedSockets)) + const isEth = Boolean(equipUi.ethereal || rawEquip?.flags?.ethereal) + const socketedRaw = createSocketedBaseItem(eqCode, finalSockets, { + ilvl, + quality: ItemQuality.NORMAL, + ethereal: isEth, + baseRolledProps: rawEquip?.rolledProps, + }) + const socketedUi = toUiItemWithId(socketedRaw, equipUi.id) + return { + matched: true, + recipeName: recipeLabel, + resultItems: [{ item: socketedUi, col: 0, row: 0 }], + } + } + } + } + } + } + + // 3. Check Rune Upgrade Recipes + const runeCodes = codes.filter(c => isRuneCode(c)) + if (runeCodes.length >= 2 && runeCodes.every(c => c === runeCodes[0])) { + const runeNum = Number(runeCodes[0]!.slice(1)) + const recipe = RUNE_UPGRADE_RECIPES[runeNum] + if (recipe && runeNum < 33 && runeCodes.length === recipe.count) { + const nonRunes = codes.filter(c => !isRuneCode(c)) + // Match either exact 1.13c gem or pure rune stack (3x/2x runes) + const gemMatches = + !recipe.gemCode + ? nonRunes.length === 0 + : (nonRunes.length === 1 && nonRunes[0] === recipe.gemCode) || nonRunes.length === 0 + if (gemMatches) { + const nextCode = `r${String(runeNum + 1).padStart(2, '0')}` + const nextRuneRaw = createSocketableItem(nextCode) + const nextRuneUi = toUiItemWithId(nextRuneRaw, `cube_rune_${nextCode}_${Date.now()}`) + return { + matched: true, + recipeName: `Upgrade Rune (${runeCodes[0]} -> ${nextCode})`, + resultItems: [{ item: nextRuneUi, col: 0, row: 0 }], + } + } + } + } + + // 4. Check 3x Same Gem Upgrade Recipes + if (codes.length === 3 && codes[0] === codes[1] && codes[1] === codes[2]) { + const gemCode = codes[0]! + for (const chain of GEM_UPGRADE_CHAINS) { + const idx = chain.indexOf(gemCode) + if (idx !== -1 && idx + 1 < chain.length) { + const nextGemCode = chain[idx + 1]! + const nextGemRaw = createSocketableItem(nextGemCode) + const nextGemUi = toUiItemWithId(nextGemRaw, `cube_gem_${nextGemCode}_${Date.now()}`) + return { + matched: true, + recipeName: `Upgrade Gem (${gemCode} -> ${nextGemCode})`, + resultItems: [{ item: nextGemUi, col: 0, row: 0 }], + } + } + } + } + + return { matched: false, resultItems: cubePlacements } +} diff --git a/src/game/tooltip-i18n.ts b/src/game/tooltip-i18n.ts index 3dfb07c..8793d4f 100644 --- a/src/game/tooltip-i18n.ts +++ b/src/game/tooltip-i18n.ts @@ -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 = { "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 = { "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' diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index f7245dd..6927a01 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -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 diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 1665f8b..9adeb5c 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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 } diff --git a/tests/runeword-system.test.ts b/tests/runeword-system.test.ts new file mode 100644 index 0000000..7ef4406 --- /dev/null +++ b/tests/runeword-system.test.ts @@ -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 , 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) + }) +})