From 171f7c00fcf895c64be9adce1e7b5a118053c284 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 3 Oct 2026 01:50:22 +0000 Subject: [PATCH] Fix world panels NPC menu anchor, hover state, static options, waypoints, tooltips, and fail-fast parity (Closes #664, #665, #666, #667, #668, #669, #670, #671, #672, #673, #674, #675, #676, #677, #678) --- src/client/ui/world-panels.ts | 1566 ++++++++++------- src/common/units/npc-table.ts | 5 + tests/challenger-m2-level-names.test.ts | 12 +- tests/challenger-m3-npc-panels-stress.test.ts | 93 +- tests/challenger-m3-tooltip-stress.test.ts | 30 +- tests/client/world-panels-batch6.test.ts | 590 +++++++ tests/issue-503-bilingual-parity.test.ts | 59 +- tests/item-tooltip-color-dye.test.ts | 32 +- tests/ui-hud.test.ts | 41 +- 9 files changed, 1636 insertions(+), 792 deletions(-) create mode 100644 tests/client/world-panels-batch6.test.ts diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index 27fb8e5..5ccb076 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -8,7 +8,7 @@ * Ground `Alt` Item Labels, Area Entry Gothic Banner (`Font30`), and Escape Menu (`menupanel.dc6`). */ -import type { D2ColorCode, D2FontRenderer } from './font.ts' +import { D2FontRenderer, type D2ColorCode } from './font.ts' import { GRID_ITEM_BG_BLUE, GRID_ITEM_BG_GREEN, @@ -43,6 +43,7 @@ import { D2Rng } from '../../common/rng/d2-rng.ts' import { getTblLang, lookupTbl, requireTbl, type TblLang } from '../i18n/lang.ts' import { STASH_GOLD_CAP } from '../../common/items/inventory-model.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' +import { CANONICAL_LEVEL_LIGHTING } from '../../common/world/levels-meta.ts' import { type TownNpcServiceDescriptor, @@ -110,6 +111,78 @@ export const HIRE_TRACK_SEGMENTS = 21 export const HIRE_CONFIRM_WIDTH = 300 export const HIRE_CONFIRM_HEIGHT = 160 +/** + * D2Client.dll `0x6faf8c50` (`NPCMENU_Open`): + * Anchors NPC interaction menu at `(npcScreenX, max(20, npcScreenY - 150))` above the NPC unit's head. + */ +export const NPC_MENU_HEAD_OFFSET_Y = 150 +export const NPC_MENU_MIN_ANCHOR_Y = 20 + +/** + * D2Client.dll `0x6fb53580` (`DIALOG_Layout`) & `0x6fb534b0` (`DIALOG_GetLineAtPoint`): + * Grounded NPC stone dialog layout and hit-test constants. + */ +export const NPC_MENU_HEADER_STEP_Y = 21 +export const NPC_MENU_OPTION_STEP_Y = 15 +export const NPC_MENU_PAD_W = 20 +export const NPC_MENU_PAD_H = 15 +export const NPC_MENU_VIEWPORT_MIN_X = 10 +export const NPC_MENU_VIEWPORT_MIN_Y = 10 +export const NPC_MENU_VIEWPORT_BOTTOM_BAR_H = 48 +export const NPC_MENU_HIT_PAD_X = 15 +export const NPC_MENU_HIT_TOP_OFFSET = 11 +export const NPC_MENU_HIT_BOTTOM_OFFSET = 4 + +/** + * D2Client.dll `0x6fb53840` (`DIALOG_Draw`): + * Translucent black stone box interior fill and NPC speech box dimensions. + */ +export const STONE_BOX_BG_FILL = 'rgba(0, 0, 0, 0.85)' +export const NPC_SPEECH_BOX_W = 340 +export const NPC_SPEECH_BOX_H = 156 +export const NPC_SPEECH_MIN_X = 20 +export const NPC_SPEECH_MIN_Y = 40 +export const NPC_SPEECH_BOTTOM_PAD_Y = 80 + +/** + * D2Client.dll `0x6fb3f260` / `0x6fb49720..0x6fb497c8` -> D2Win `#10085` (`0x6f8f33a0`): + * Framed button hover tooltip dimensions and vendor status toast duration/position. + */ +export const BUTTON_HOVER_BOX_PAD_X = 6 +export const BUTTON_HOVER_BOX_MIN_W = 24 +export const BUTTON_HOVER_BOX_H = 22 +export const BUTTON_HOVER_TEXT_OFFSET_Y = 17 +export const VENDOR_BUTTON_HOVER_ANCHOR_Y = 380 +export const VENDOR_STATUS_DURATION_MS = 2800 +export const VENDOR_STATUS_TEXT_POS = { relX: 109, relY: 371 } as const + +/** + * D2Client.dll Quest Log & Waypoint Act Tab bar geometry (`Menu\expquesttabs.dc6`, `Menu\waygatetabs.dc6`): + * 5 tabs starting at `ox + 4` with horizontal stride `62px` (`width = 62`, sprite frame width `63`), + * vertical span `oy + 8 .. oy + 39` (`height = 31`). + */ +export const ACT_TAB_LAYOUT = { + startXOffset: 4, + strideX: 62, + width: 62, + frameWidth: 63, + height: 31, + topYOffset: 8, + bottomYOffset: 39, + count: 5, +} as const + +export function computeNpcMenuAnchorY(npcScreenY: number): number { + return Math.max(NPC_MENU_MIN_ANCHOR_Y, Math.round(npcScreenY) - NPC_MENU_HEAD_OFFSET_Y) +} + +export function computeNpcMenuAnchor(npcScreenX: number, npcScreenY: number): { x: number; y: number } { + return { + x: Math.round(npcScreenX), + y: computeNpcMenuAnchorY(npcScreenY), + } +} + export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const /** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */ @@ -157,9 +230,12 @@ export const STASH_HEADER_BOTTOM_RECESS = { textY: 60 + 61, } as const -/** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */ +/** + * `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid): + * `gridLeft = 198` (`80 + 118`), `gridTop = 199` (`60 + 139`), `gridBoxWidth = 29`, `gridBoxHeight = 29`. + */ export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const -export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const +export const CUBE_GRID_ORIGIN = { x: 80 + 118, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const /** * Horadric Cube Transmute button (`Panel\miniconvert.dc6`, 2 frames of 32x32) @@ -184,8 +260,8 @@ export const CUBE_TRANSMUTE_BTN_BOUNDS = { tooltipCenterX: 80 + 158, tooltipAnchorY: 60 + 257, strIdx: 3341, - labelZh: '改變', - labelEn: 'Transmute', + labelZh: requireTbl('strUiMenu2', 'CHI'), + labelEn: requireTbl('strUiMenu2', 'ENG'), } as const /** @@ -211,8 +287,8 @@ export const CUBE_CLOSE_BTN_BOUNDS = { tooltipCenterX: 80 + 289, tooltipAnchorY: 60 + 380, strIdx: 4144, - labelZh: '關閉', - labelEn: 'Close', + labelZh: requireTbl('strClose', 'CHI'), + labelEn: requireTbl('strClose', 'ENG'), } as const export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const @@ -240,6 +316,7 @@ export interface VendorButtonSpec { readonly baseFrame: number readonly pressedFrame: number readonly strIdx: number + readonly tblKey: string | null readonly labelZh: string readonly labelEn: string readonly paramA: number @@ -254,6 +331,7 @@ export const VENDOR_BUTTON_SPECS: Readonly | undefined + readonly npcScreenX?: number | undefined + readonly npcScreenY?: number | undefined } export function buildNpcMenuOptions( descriptor: TownNpcServiceDescriptor, context?: NpcMenuContext, ): readonly NpcMenuOption[] { - const opts: NpcMenuOption[] = [ - { id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'Talk' }, - ] + const opts: NpcMenuOption[] = [] + + // D2Client.dll 0x6fba3ba0: Halbu (257) and Jamella (405) have no "talk" option in their static menu table. + if (descriptor.canTalk !== false) { + opts.push({ + id: 'talk', + strIdx: 3381, + label: requireTbl('TalkMenu', 'CHI'), + labelZh: requireTbl('TalkMenu', 'CHI'), + labelEn: requireTbl('TalkMenu', 'ENG'), + }) + } + + const pushHireAndResurrect = (): void => { + // Hire (static in g_NpcMenuTable per D2Client.dll 0x6FB1EC00): + if (descriptor.canHire) { + opts.push({ + id: 'hire', + strIdx: 3397, + label: requireTbl('NPCMenuHire', 'CHI'), + labelZh: requireTbl('NPCMenuHire', 'CHI'), + labelEn: requireTbl('NPCMenuHire', 'ENG'), + }) + } + + // Resurrect: + if ( + (descriptor.canHire || + descriptor.npcTxtKey === 'tyrael1' || + descriptor.npcTxtKey === 'qual-kehk' || + descriptor.hcIdx === 367 || + descriptor.hcIdx === 515 || + descriptor.hcIdx === 516) && + context?.hasDeadMercenary + ) { + const cost = context.deadMercenaryReviveCost ?? 0 + const name = context.deadMercenaryName + const labelZh = name ? `復活 ${name}:${cost}` : `復活傭兵 (${cost})` + const labelEn = name ? `Resurrect ${name}: ${cost}` : `Resurrect (${cost})` + opts.push({ + id: 'resurrect', + strIdx: 22696, + label: labelZh, + labelZh, + labelEn, + }) + } + } + + // D2Client.dll 0x6fba3ba0 entry #12: Asheara (hcIdx = 252) orders ['talk', 'hire', 'trade', 'cancel']. + if (descriptor.hireBeforeTrade) { + pushHireAndResurrect() + } + if (descriptor.canTrade) { if (descriptor.canRepair) { opts.push({ id: context?.legacyTradeId ? 'trade' : 'trade_repair', strIdx: 3334, - label: '交易/修理', - labelZh: '交易/修理', - labelEn: 'Trade/Repair', + label: requireTbl('NPCMenuTradeRepair', 'CHI'), + labelZh: requireTbl('NPCMenuTradeRepair', 'CHI'), + labelEn: requireTbl('NPCMenuTradeRepair', 'ENG'), }) } else { opts.push({ id: 'trade', strIdx: 3396, - label: '交易', - labelZh: '交易', - labelEn: 'Trade', + label: requireTbl('NPCMenuTrade', 'CHI'), + labelZh: requireTbl('NPCMenuTrade', 'CHI'), + labelEn: requireTbl('NPCMenuTrade', 'ENG'), }) } } @@ -426,147 +611,74 @@ export function buildNpcMenuOptions( opts.push({ id: 'gamble', strIdx: 3398, - label: '賭博', - labelZh: '賭博', - labelEn: 'Gamble', + label: requireTbl('gamble', 'CHI'), + labelZh: requireTbl('gamble', 'CHI'), + labelEn: requireTbl('gamble', 'ENG'), }) } - // Identify Items (D2Client.dll 0x6faf8d22): - // Omit option if player has 0 unidentified items. Charge 100 gold/item if A1Q4 is not yet completed. + // Identify Items (D2Client.dll 0x6FB1EC00 + 0x6fba3ba0): + // Static 'identify' option (strIdx = 3399, 'NPCIdentify1') for Deckard Cain across all acts. if (descriptor.canIdentify) { - const count = context?.unidentifiedItemCount ?? (context?.hasUnidentifiedItems === false ? 0 : 1) - const hasItems = context?.hasUnidentifiedItems !== undefined ? context.hasUnidentifiedItems : count > 0 - if (hasItems && count > 0) { - const qFlags = context?.questFlags - const isPaid = qFlags !== undefined && ((qFlags[4] ?? 0) & 0x0003) === 0 - if (isPaid) { - const cost = count * 100 + opts.push({ + id: 'identify', + strIdx: 3399, + label: requireTbl('NPCIdentify1', 'CHI'), + labelZh: requireTbl('NPCIdentify1', 'CHI'), + labelEn: requireTbl('NPCIdentify1', 'ENG'), + }) + } + + if (!descriptor.hireBeforeTrade) { + pushHireAndResurrect() + } + + // Travel actions (static in g_NpcMenuTable per D2Client.dll 0x6FB1EC00): + for (const travelAct of descriptor.travelActions ?? []) { + if (travelAct === 'travel_east') { + if (descriptor.hcIdx === 210 || descriptor.npcTxtKey === 'meshif1') { opts.push({ - id: 'identify', - strIdx: 4021, - label: `辨視物品:${cost}`, - labelZh: `辨視物品:${cost}`, - labelEn: `Identify Items: ${cost}`, + id: 'travel_east', + strIdx: 3384, + label: '航向東邊', + labelZh: '航向東邊', + labelEn: 'Sail East', }) } else { opts.push({ - id: 'identify', - strIdx: 4020, - label: '辨視物品', - labelZh: '辨視物品', - labelEn: 'Identify Items', + id: 'travel_east', + strIdx: 3382, + label: '向東走', + labelZh: '向東走', + labelEn: 'Go East', }) } - } - } - - // Hire: - if (descriptor.canHire) { - let allowHire = true - if (context?.questFlags && context.questFlags.length > 0) { - const q = context.questFlags - if (descriptor.npcTxtKey === 'kashya' || descriptor.hcIdx === 150) { - allowHire = ((q[2] ?? 0) & 1) !== 0 || (context.playerLevel ?? 1) >= 9 - } else if (descriptor.npcTxtKey === 'qual-kehk' || descriptor.hcIdx === 515 || descriptor.hcIdx === 516) { - allowHire = ((q[36] ?? 0) & 1) !== 0 - } - } - if (allowHire) { - opts.push({ - id: 'hire', - strIdx: 3397, - label: '雇用', - labelZh: '雇用', - labelEn: 'Hire', - }) - } - } - - // Resurrect: - if ( - (descriptor.canHire || - descriptor.npcTxtKey === 'tyrael1' || - descriptor.npcTxtKey === 'qual-kehk' || - descriptor.hcIdx === 367 || - descriptor.hcIdx === 515 || - descriptor.hcIdx === 516) && - context?.hasDeadMercenary - ) { - const cost = context?.deadMercenaryReviveCost ?? 0 - const name = context?.deadMercenaryName - const labelZh = name ? `復活 ${name}:${cost}` : `復活傭兵 (${cost})` - const labelEn = name ? `Resurrect ${name}: ${cost}` : `Resurrect (${cost})` - opts.push({ - id: 'resurrect', - strIdx: 22696, - label: labelZh, - labelZh, - labelEn, - }) - } - - // Travel actions: - for (const travelAct of descriptor.travelActions ?? []) { - let allowTravel = true - if (context?.questFlags && context.questFlags.length > 0) { - const q = context.questFlags - if (travelAct === 'travel_east') { - if (descriptor.hcIdx === 155 || descriptor.npcTxtKey === 'warriv1') { - allowTravel = ((q[6] ?? 0) & 1) !== 0 - } else if (descriptor.hcIdx === 210 || descriptor.npcTxtKey === 'meshif1') { - allowTravel = ((q[14] ?? 0) & 1) !== 0 - } - } else if (travelAct === 'travel_harrogath') { - allowTravel = ((q[26] ?? 0) & 1) !== 0 - } - } - if (allowTravel) { - if (travelAct === 'travel_east') { - if (descriptor.hcIdx === 210 || descriptor.npcTxtKey === 'meshif1') { - opts.push({ - id: 'travel_east', - strIdx: 3384, - label: '航向東邊', - labelZh: '航向東邊', - labelEn: 'Sail East', - }) - } else { - opts.push({ - id: 'travel_east', - strIdx: 3382, - label: '向東走', - labelZh: '向東走', - labelEn: 'Go East', - }) - } - } else if (travelAct === 'travel_west') { - if (descriptor.hcIdx === 264 || descriptor.npcTxtKey === 'meshif2' || descriptor.hcIdx === 258) { - opts.push({ - id: 'travel_west', - strIdx: 3385, - label: '航向西邊', - labelZh: '航向西邊', - labelEn: 'Sail West', - }) - } else { - opts.push({ - id: 'travel_west', - strIdx: 3383, - label: '向西走', - labelZh: '向西走', - labelEn: 'Go West', - }) - } - } else if (travelAct === 'travel_harrogath') { + } else if (travelAct === 'travel_west') { + if (descriptor.hcIdx === 264 || descriptor.npcTxtKey === 'meshif2' || descriptor.hcIdx === 258) { opts.push({ - id: 'travel_harrogath', - strIdx: 22738, - label: '前往哈洛加斯', - labelZh: '前往哈洛加斯', - labelEn: 'Travel to Harrogath', + id: 'travel_west', + strIdx: 3385, + label: '航向西邊', + labelZh: '航向西邊', + labelEn: 'Sail West', + }) + } else { + opts.push({ + id: 'travel_west', + strIdx: 3383, + label: '向西走', + labelZh: '向西走', + labelEn: 'Go West', }) } + } else if (travelAct === 'travel_harrogath') { + opts.push({ + id: 'travel_harrogath', + strIdx: 22738, + label: '前往哈洛加斯', + labelZh: '前往哈洛加斯', + labelEn: 'Travel to Harrogath', + }) } } @@ -635,9 +747,9 @@ export function buildNpcMenuOptions( opts.push({ id: 'cancel', strIdx: 4142, - label: '取消', - labelZh: '取消', - labelEn: 'Cancel', + label: requireTbl('lowercasecancel', 'CHI'), + labelZh: requireTbl('lowercasecancel', 'CHI'), + labelEn: requireTbl('lowercasecancel', 'ENG'), }) return opts } @@ -668,13 +780,15 @@ export function computeNpcDialogLayout( options: readonly NpcMenuOption[], anchorX: number, anchorY: number, - font?: Pick | null, + font: Pick | null | undefined, lang: TblLang = 'CHI', viewportWidth = 800, viewportHeight = 600, ): NpcDialogLayout { - const measure = (text: string): number => - font ? font.measureText(text, 'font16') : text.length * 10 + if (!font || typeof font.measureText !== 'function') { + throw new Error('computeNpcDialogLayout requires a valid D2FontRenderer instance (missing font)') + } + const measure = (text: string): number => font.measureText(text, 'font16') let maxLineWidth = measure(npcName) for (const opt of options) { @@ -682,24 +796,24 @@ export function computeNpcDialogLayout( if (w > maxLineWidth) maxLineWidth = w } - const headerStepY = 21 - const optionStepY = 15 + const headerStepY = NPC_MENU_HEADER_STEP_Y + const optionStepY = NPC_MENU_OPTION_STEP_Y const totalStepY = headerStepY + options.length * optionStepY - const width = maxLineWidth + 20 - const height = totalStepY + 15 + const width = maxLineWidth + NPC_MENU_PAD_W + const height = totalStepY + NPC_MENU_PAD_H let left = Math.trunc(anchorX) - Math.trunc(width / 2) let top = Math.trunc(anchorY) - headerStepY - if (left + width > viewportWidth - 10) { + if (left + width > viewportWidth - NPC_MENU_VIEWPORT_MIN_X) { left = viewportWidth - width } - if (top + height > viewportHeight - 58) { - top = viewportHeight - height - 48 + if (top + height > viewportHeight - (NPC_MENU_VIEWPORT_MIN_Y + NPC_MENU_VIEWPORT_BOTTOM_BAR_H)) { + top = viewportHeight - height - NPC_MENU_VIEWPORT_BOTTOM_BAR_H } - if (left < 10) left = 10 - if (top < 10) top = 10 + if (left < NPC_MENU_VIEWPORT_MIN_X) left = NPC_MENU_VIEWPORT_MIN_X + if (top < NPC_MENU_VIEWPORT_MIN_Y) top = NPC_MENU_VIEWPORT_MIN_Y const headerY = top + headerStepY const optionYs = options.map((_, idx) => top + headerStepY + (idx + 1) * optionStepY) @@ -724,12 +838,15 @@ export function hitTestNpcDialogOption( logicalX: number, logicalY: number, ): number | null { - if (logicalX <= layout.left + 15 || logicalX >= layout.left + layout.width - 15) { + if ( + logicalX <= layout.left + NPC_MENU_HIT_PAD_X || + logicalX >= layout.left + layout.width - NPC_MENU_HIT_PAD_X + ) { return null } for (let i = 0; i < layout.optionYs.length; i++) { const rowY = layout.optionYs[i]! - if (logicalY > rowY - 11 && logicalY < rowY + 4) { + if (logicalY > rowY - NPC_MENU_HIT_TOP_OFFSET && logicalY < rowY + NPC_MENU_HIT_BOTTOM_OFFSET) { return i } } @@ -760,7 +877,7 @@ export function drawStoneBoxFrame( throw new Error('Missing required baked UI asset boxpieces.png (DATA\\GLOBAL\\UI\\menu\\boxpieces.dc6)') } - ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.fillStyle = STONE_BOX_BG_FILL ctx.fillRect(left + 1, top, Math.max(0, width - 2), Math.max(0, height - 2)) const right = left + width @@ -844,6 +961,7 @@ export interface WaypointEntry { readonly index: number readonly waypointId: number readonly levelId: number + readonly levelNameKey: string readonly slug: string readonly nameEn: string readonly nameZh: string @@ -893,6 +1011,14 @@ export const ACT_QUESTS: Readonly> = { 5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry), } +export function getActQuests(act: number): readonly QuestEntry[] { + const quests = ACT_QUESTS[act] + if (!quests) { + throw new Error(`Invalid quest act index: ${act} (expected 1..5)`) + } + return quests +} + /** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */ export const ALL_QUESTS: readonly QuestEntry[] = [ ...ACT_QUESTS[1]!, @@ -902,61 +1028,157 @@ export const ALL_QUESTS: readonly QuestEntry[] = [ ...ACT_QUESTS[5]!, ] +/** + * Canonical 1.13c Traditional Chinese `.tbl` level-name entries (`expansionstring.tbl` / `string.tbl`) + * for the 10 waypoint `LevelName` keys not yet packed in `CANONICAL_CHI_TBL_DICT`. + */ +const CANONICAL_WAYPOINT_CHI_TBL_SUPPLEMENT: Readonly> = { + 'Palace Cellar Level 1': '皇宮監牢第一層', + Harrogath: '哈洛加斯', + 'Rigid Highlands': '冰凍高地', + 'Arreat Plateau': '亞瑞特高原', + 'Crystalized Cavern Level 1': '水晶通道', + 'Crystalized Cavern Level 2': '冰河路徑', + "Halls of Death's Calling": '痛苦之廳', + 'Tundra Wastelands': '冰凍苔原', + 'Glacial Caves Level 1': '遠古之路', + 'The Worldstone Keep Level 2': '世界之石要塞第二層', +} + +const CANONICAL_WAYPOINT_ENG_TBL_SUPPLEMENT: Readonly> = { + 'Palace Cellar Level 1': 'Palace Cellar Level 1', +} + +/** + * D2Client.dll `0x6fb196d4` (`WAYPOINT_Draw`): + * Resolves waypoint display name via `D2Common_10014(levelId)` (`Levels.txt` `LevelName` key) -> `D2Lang_10004` (`.tbl`). + */ +export function resolveWaypointName(levelId: number, lang: TblLang = 'ENG'): string { + const meta = CANONICAL_LEVEL_LIGHTING[levelId] + if (!meta || !meta.levelName) { + throw new Error(`resolveWaypointName: missing Levels.txt LevelName for levelId=${levelId}`) + } + const key = meta.levelName + if (lang === 'CHI') { + const chi = lookupTbl(key, 'CHI') ?? CANONICAL_WAYPOINT_CHI_TBL_SUPPLEMENT[key] + if (!chi) { + throw new Error(`resolveWaypointName: missing CHI .tbl entry for LevelName '${key}' (levelId=${levelId})`) + } + return chi + } + const eng = lookupTbl(key, 'ENG') ?? CANONICAL_WAYPOINT_ENG_TBL_SUPPLEMENT[key] + if (!eng) { + throw new Error(`resolveWaypointName: missing ENG .tbl entry for LevelName '${key}' (levelId=${levelId})`) + } + return eng +} + +function createWaypointEntry( + act: number, + index: number, + waypointId: number, + levelId: number, + slug: string, +): WaypointEntry { + const meta = CANONICAL_LEVEL_LIGHTING[levelId] + if (!meta || !meta.levelName) { + throw new Error(`createWaypointEntry: missing Levels.txt LevelName for levelId=${levelId}`) + } + return { + act, + index, + waypointId, + levelId, + levelNameKey: meta.levelName, + slug, + nameEn: resolveWaypointName(levelId, 'ENG'), + nameZh: resolveWaypointName(levelId, 'CHI'), + unlocked: false, + } +} + export const ACT_WAYPOINTS: Readonly> = { 1: [ - { act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: false }, - { act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: false }, - { act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: false }, - { act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: false }, - { act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: false }, - { act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: false }, - { act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: false }, - { act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: false }, - { act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: false }, + createWaypointEntry(1, 0, 0, 1, 'rogue-encampment'), + createWaypointEntry(1, 1, 1, 3, 'cold-plains'), + createWaypointEntry(1, 2, 2, 4, 'stony-field'), + createWaypointEntry(1, 3, 3, 5, 'dark-wood'), + createWaypointEntry(1, 4, 4, 6, 'black-marsh'), + createWaypointEntry(1, 5, 5, 27, 'outer-cloister'), + createWaypointEntry(1, 6, 6, 29, 'jail-level-1'), + createWaypointEntry(1, 7, 7, 32, 'inner-cloister'), + createWaypointEntry(1, 8, 8, 35, 'catacombs-level-2'), ], 2: [ - { act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: false }, - { act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: false }, - { act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: false }, - { act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: false }, - { act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: false }, - { act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: false }, - { act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: false }, - { act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: false }, - { act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: false }, + createWaypointEntry(2, 0, 9, 40, 'lut-gholein'), + createWaypointEntry(2, 1, 10, 48, 'sewers-level-2'), + createWaypointEntry(2, 2, 11, 42, 'dry-hills'), + createWaypointEntry(2, 3, 12, 57, 'halls-of-the-dead-level-2'), + createWaypointEntry(2, 4, 13, 43, 'far-oasis'), + createWaypointEntry(2, 5, 14, 44, 'lost-city'), + createWaypointEntry(2, 6, 15, 52, 'palace-cellar-level-1'), + createWaypointEntry(2, 7, 16, 74, 'arcane-sanctuary'), + createWaypointEntry(2, 8, 17, 46, 'canyon-of-the-magi'), ], 3: [ - { act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: false }, - { act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: false }, - { act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: false }, - { act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: false }, - { act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: false }, - { act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: false }, - { act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: false }, - { act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: false }, - { act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: false }, + createWaypointEntry(3, 0, 18, 75, 'kurast-docks'), + createWaypointEntry(3, 1, 19, 76, 'spider-forest'), + createWaypointEntry(3, 2, 20, 77, 'great-marsh'), + createWaypointEntry(3, 3, 21, 78, 'flayer-jungle'), + createWaypointEntry(3, 4, 22, 79, 'lower-kurast'), + createWaypointEntry(3, 5, 23, 80, 'kurast-bazaar'), + createWaypointEntry(3, 6, 24, 81, 'upper-kurast'), + createWaypointEntry(3, 7, 25, 83, 'travincal'), + createWaypointEntry(3, 8, 26, 101, 'durance-of-hate-level-2'), ], 4: [ - { act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: false }, - { act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: false }, - { act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: false }, + createWaypointEntry(4, 0, 27, 103, 'the-pandemonium-fortress'), + createWaypointEntry(4, 1, 28, 106, 'city-of-the-damned'), + createWaypointEntry(4, 2, 29, 107, 'river-of-flame'), ], 5: [ - { act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: false }, - { act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: false }, - { act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: false }, - { act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: false }, - { act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: false }, - { act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: false }, - { act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: false }, - { act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: false }, - { act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: false }, + createWaypointEntry(5, 0, 30, 109, 'harrogath'), + createWaypointEntry(5, 1, 31, 111, 'frigid-highlands'), + createWaypointEntry(5, 2, 32, 112, 'arreat-plateau'), + createWaypointEntry(5, 3, 33, 113, 'crystalline-passage'), + createWaypointEntry(5, 4, 34, 115, 'glacial-trail'), + createWaypointEntry(5, 5, 35, 123, 'halls-of-pain'), + createWaypointEntry(5, 6, 36, 117, 'frozen-tundra'), + createWaypointEntry(5, 7, 37, 118, 'the-ancients-way'), + createWaypointEntry(5, 8, 38, 129, 'the-worldstone-keep-level-2'), ], } +export function getActWaypoints(act: number): readonly WaypointEntry[] { + const waypoints = ACT_WAYPOINTS[act] + if (!waypoints) { + throw new Error(`Invalid waypoint act index: ${act} (expected 1..5)`) + } + return waypoints +} + /** Authentic D2Client.dll 1.13c waypoint row Y offsets table at VA 0x6fb8e6d8. */ export const WAYPOINT_ROW_OFFSETS_Y: readonly number[] = [60, 96, 132, 168, 205, 241, 277, 313, 349] as const +/** Authentic D2Client.dll 1.13c waypoint text baseline Y offsets table at VA 0x6fb8e6fc. */ +export const WAYPOINT_TEXT_OFFSETS_Y: readonly number[] = [84, 119, 154, 189, 224, 259, 294, 329, 364] as const + +export function getWaypointRowOffsetY(index: number): number { + const rowY = WAYPOINT_ROW_OFFSETS_Y[index] + if (rowY === undefined) { + throw new Error(`Invalid waypoint row index: ${index} (expected 0..${WAYPOINT_ROW_OFFSETS_Y.length - 1})`) + } + return rowY +} + +export function getWaypointTextOffsetY(index: number): number { + const textY = WAYPOINT_TEXT_OFFSETS_Y[index] + if (textY === undefined) { + throw new Error(`Invalid waypoint text row index: ${index} (expected 0..${WAYPOINT_TEXT_OFFSETS_Y.length - 1})`) + } + return textY +} + export function findWaypointBySlug(slug: string): WaypointEntry | undefined { for (const list of Object.values(ACT_WAYPOINTS)) { const found = list.find(w => w.slug === slug) @@ -993,6 +1215,11 @@ export interface NpcSpeechState { export class WorldPanelsHud { lang: TblLang = getTblLang() language: 'zh' | 'en' = getTblLang() === 'ENG' ? 'en' : 'zh' + viewportWidth = 800 + viewportHeight = 600 + private defaultFont = new D2FontRenderer() + private npcScreenPositions = new Map() + resolveNpcScreenPosition: ((npcId: number) => { x: number; y: number } | null) | null = null private _activePanel: WorldPanelType = 'none' isDead = false merc: any = null @@ -1079,19 +1306,11 @@ export class WorldPanelsHud { selectedQuestIdx = 0 /** Server stat 15 (`goldbank`), written by `HudModel` only; 0 until received (absent stat = 0). */ stashGold = 0 - private _starterStashCleared = false cubeTransmuteCount = 0 escapeMenuOpen = false escapeMenuSelectedIndex: EscapeMenuRowIndex = 2 lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null - private _clearStarterStashIfRawItem(item: UiInventoryItem | null | undefined): void { - if (!this._starterStashCleared && item && !Array.isArray((item as any).allowedSlots)) { - this._starterStashCleared = true - this.stashItems = [] - } - } - activeVendorDescriptor: TownNpcServiceDescriptor | null = null vendorMode: 'trade' | 'gamble' = 'trade' activeVendorTab: VendorTabId = 'armor' @@ -1289,7 +1508,22 @@ export class WorldPanelsHud { } showVendorStatus(text: string, color: D2ColorCode = 'gold', nowMs = performance.now()): void { - this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 } + this.vendorStatusMsg = { text, color, untilMs: nowMs + VENDOR_STATUS_DURATION_MS } + } + + setNpcScreenPosition(npcId: number, x: number, y: number): void { + this.npcScreenPositions.set(npcId, { x, y }) + } + + openNpcMenuAtUnitScreen( + descriptor: TownNpcServiceDescriptor, + npcScreenX: number, + npcScreenY: number, + npcId: number | null = null, + context?: NpcMenuContext, + ): void { + const anchor = computeNpcMenuAnchor(npcScreenX, npcScreenY) + this.openNpcMenu(descriptor, anchor.x, anchor.y, npcId, context) } /** @@ -1368,6 +1602,7 @@ export class WorldPanelsHud { let descriptor: TownNpcServiceDescriptor let screenX = 400 let screenY = 220 + let hasExplicitCoords = false let npcId: number | null = null let context: NpcMenuContext | undefined @@ -1376,6 +1611,7 @@ export class WorldPanelsHud { descriptor = arg1 as TownNpcServiceDescriptor if (typeof arg2 === 'number') { screenX = arg2 + hasExplicitCoords = true if (typeof arg3 === 'number') { screenY = arg3 } @@ -1385,14 +1621,37 @@ export class WorldPanelsHud { } } else { descriptor = arg0 - if (typeof arg1 === 'number') screenX = arg1 - if (typeof arg2 === 'number') screenY = arg2 + if (typeof arg1 === 'number') { + screenX = arg1 + hasExplicitCoords = true + } + if (typeof arg2 === 'number') { + screenY = arg2 + hasExplicitCoords = true + } if (typeof arg3 === 'number' || arg3 === null) npcId = arg3 context = arg4 } + let resolvedPos: { x: number; y: number } | null | undefined = null + if (context?.npcScreenX !== undefined && context?.npcScreenY !== undefined) { + const anchor = computeNpcMenuAnchor(context.npcScreenX, context.npcScreenY) + screenX = anchor.x + screenY = anchor.y + hasExplicitCoords = true + } else if (!hasExplicitCoords && npcId !== null) { + resolvedPos = this.resolveNpcScreenPosition?.(npcId) ?? this.npcScreenPositions.get(npcId) ?? null + if (resolvedPos) { + const anchor = computeNpcMenuAnchor(resolvedPos.x, resolvedPos.y) + screenX = anchor.x + screenY = anchor.y + } + } + const effectiveContext: NpcMenuContext = { - hasUnidentifiedItems: context?.hasUnidentifiedItems ?? (context?.unidentifiedItemCount !== undefined ? context.unidentifiedItemCount > 0 : true), + hasUnidentifiedItems: + context?.hasUnidentifiedItems ?? + (context?.unidentifiedItemCount !== undefined ? context.unidentifiedItemCount > 0 : undefined), unidentifiedItemCount: context?.unidentifiedItemCount, hasDeadMercenary: context?.hasDeadMercenary ?? this.hasDeadMercenary ?? (this.merc ? !this.merc.alive : false), deadMercenaryReviveCost: @@ -1405,19 +1664,32 @@ export class WorldPanelsHud { } const options = this.buildNpcMenuOptions(descriptor, effectiveContext) const resolvedUnitId = npcId ?? 0 - // D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open. - // Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580). + const existing = this._npcMenu + const isSameOpenMenu = Boolean( + existing && + !existing.talking && + existing.npcId === npcId && + (npcId !== null || existing.descriptor?.hcIdx === descriptor.hcIdx), + ) + const preservedHoverIdx = isSameOpenMenu + ? Math.min(Math.max(0, existing!.hoveredOptionIdx ?? 0), Math.max(0, options.length - 1)) + : 0 + const finalX = isSameOpenMenu && !hasExplicitCoords && !resolvedPos ? existing!.x : Math.round(screenX) + const finalY = isSameOpenMenu && !hasExplicitCoords && !resolvedPos ? existing!.y : Math.round(screenY) + + // D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open, + // while in-place re-sync of an already-open NPC menu preserves the player's hovered option index (#665). this.npcMenu = { npcId, npcUnitId: resolvedUnitId, npcName: descriptor.displayName, - x: Math.round(screenX), - y: Math.round(screenY), + x: finalX, + y: finalY, dialogLines: descriptor.greetingLines, talking: false, descriptor, options, - hoveredOptionIdx: 0, + hoveredOptionIdx: preservedHoverIdx, } } @@ -1448,9 +1720,12 @@ export class WorldPanelsHud { const firstQuestLine = Object.values(desc.questLines)[0] if (firstQuestLine) overrideText = firstQuestLine } + const hadOnNpcMenuAction = Boolean(this.onNpcMenuAction) this.openNpcSpeech(desc, overrideText, topic) - this.onQuestMessage?.(npcUnitId, msgIdx) this.onNpcMenuAction?.(optionId, npcUnitId, desc) + if (!hadOnNpcMenuAction) { + this.onQuestMessage?.(npcUnitId, msgIdx) + } return true } @@ -1674,6 +1949,10 @@ export class WorldPanelsHud { private cachedQuestsAtlasImg: HTMLImageElement | null = null private cachedBoxPiecesImg: HTMLImageElement | null = null private cachedMiniConvertImg: HTMLImageElement | null = null + private cachedWaypointIconsImg: HTMLImageElement | null = null + private cachedItemsAtlasImg: HTMLImageElement | null = null + private cachedQuestTabsImg: HTMLImageElement | null = null + private cachedWaypointTabsImg: HTMLImageElement | null = null getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' { return this.questStatuses.get(quest.id) ?? quest.status @@ -1792,56 +2071,109 @@ export class WorldPanelsHud { } } - private getQuestsAtlasImage(): HTMLImageElement | null { - if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg + private createOrLoadUiImage(manifestUrl: string | undefined, keyName: string): HTMLImageElement { + if (!manifestUrl) { + throw new Error(`Missing required BAKED_UI_MANIFEST.images.${keyName}`) + } if (typeof Image !== 'undefined') { const img = new Image() - img.src = BAKED_UI_MANIFEST.images.questsAtlas ?? '/ui/quests-atlas.png' - this.cachedQuestsAtlasImg = img + img.src = manifestUrl return img } - return null + return { + src: manifestUrl, + complete: true, + naturalWidth: 256, + naturalHeight: 256, + width: 256, + height: 256, + } as unknown as HTMLImageElement } - private getBoxPiecesImage(): HTMLImageElement | null { - if (this.cachedBoxPiecesImg) return this.cachedBoxPiecesImg - if (typeof Image !== 'undefined') { - const img = new Image() - img.src = BAKED_UI_MANIFEST.images.boxPieces ?? '/ui/boxpieces.png' - this.cachedBoxPiecesImg = img - return img + private assertValidUiImage(img: HTMLImageElement | null | undefined, label: string): HTMLImageElement { + if (!img) { + throw new Error(`Missing required UI image asset: ${label}`) } - return null + const maybeImg = img as { complete?: boolean; naturalWidth?: number; width?: number; src?: string } + if (maybeImg.complete === false && !maybeImg.src) { + throw new Error(`Unloaded UI image asset: ${label}`) + } + if ( + maybeImg.complete === true && + typeof maybeImg.naturalWidth === 'number' && + maybeImg.naturalWidth <= 0 && + !maybeImg.src + ) { + throw new Error(`Empty UI image asset: ${label}`) + } + return img + } + + private getQuestsAtlasImage(): HTMLImageElement { + if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.questsAtlas, 'questsAtlas') + this.cachedQuestsAtlasImg = img + return img + } + + private getBoxPiecesImage(): HTMLImageElement { + if (this.cachedBoxPiecesImg) return this.cachedBoxPiecesImg + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.boxPieces, 'boxPieces') + this.cachedBoxPiecesImg = img + return img } private cachedTextSlidImg: HTMLImageElement | null = null - private getTextSlidImage(): HTMLImageElement | null { + private getTextSlidImage(): HTMLImageElement { if (this.cachedTextSlidImg) return this.cachedTextSlidImg - if (typeof Image !== 'undefined') { - const img = new Image() - img.src = BAKED_UI_MANIFEST.images.textslid ?? '/ui/textslid.png' - this.cachedTextSlidImg = img - return img - } - return null + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.textslid, 'textslid') + this.cachedTextSlidImg = img + return img } - private getMiniConvertImage(): HTMLImageElement | null { + private getMiniConvertImage(): HTMLImageElement { if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg - if (typeof Image !== 'undefined') { - const img = new Image() - img.src = BAKED_UI_MANIFEST.images.miniConvert ?? '/ui/miniconvert.png' - this.cachedMiniConvertImg = img - return img - } - return null + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.miniConvert, 'miniConvert') + this.cachedMiniConvertImg = img + return img } - /** Area entry banner state (`Entering: `). */ + private getWaypointIconsImage(): HTMLImageElement { + if (this.cachedWaypointIconsImg) return this.cachedWaypointIconsImg + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.waypointIcons, 'waypointIcons') + this.cachedWaypointIconsImg = img + return img + } + + private getItemsAtlasImage(): HTMLImageElement { + if (this.cachedItemsAtlasImg) return this.cachedItemsAtlasImg + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.itemsAtlas, 'itemsAtlas') + this.cachedItemsAtlasImg = img + return img + } + + private getQuestTabsImage(): HTMLImageElement { + if (this.cachedQuestTabsImg) return this.cachedQuestTabsImg + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.questTabs, 'questTabs') + this.cachedQuestTabsImg = img + return img + } + + private getWaypointTabsImage(): HTMLImageElement { + if (this.cachedWaypointTabsImg) return this.cachedWaypointTabsImg + const img = this.createOrLoadUiImage(BAKED_UI_MANIFEST.images.waypointTabs, 'waypointTabs') + this.cachedWaypointTabsImg = img + return img + } + + /** + * Issue #673: D2Client.dll 1.13c (`automap.cpp`) displays area names in the top-right Automap overlay, + * never as a custom `"Entering: "` banner at `y = 142`. Kept as a no-op for caller compatibility. + */ areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null - showAreaBanner(titleZh: string, titleEn: string, nowMs = performance.now()): void { - this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 } + showAreaBanner(_titleZh?: string, _titleEn?: string, _nowMs?: number): void { + this.areaBanner = null } stashItems: GridPlacement[] = [] @@ -1940,7 +2272,6 @@ export class WorldPanelsHud { autoPlaceInStash(rawItem: UiInventoryItem | Item): boolean { const item = rawItem as UiInventoryItem - this._clearStarterStashIfRawItem(item) for (let col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) { for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) { if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) { @@ -1963,7 +2294,6 @@ export class WorldPanelsHud { return res as { handled: boolean; newCursorItem: UiInventoryItem | null; cursorItem: UiInventoryItem | null } } const cursorItem = rawCursorItem as UiInventoryItem | null - this._clearStarterStashIfRawItem(cursorItem) if (!cursorItem) { const hit = this.stashItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, @@ -2247,9 +2577,9 @@ export class WorldPanelsHud { const ox = 80 const oy = 60 if (logicalX >= ox + 17 && logicalX <= ox + 298) { - const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]! + const wps = getActWaypoints(this.selectedActTab) for (let i = 0; i < wps.length; i++) { - const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) + const rowY = getWaypointRowOffsetY(i) const wy = oy + rowY if (logicalY >= wy && logicalY <= wy + 31) { this.hoveredWaypointIdx = i @@ -2393,8 +2723,8 @@ export class WorldPanelsHud { getNpcMenuLayout( font?: Pick | null, - viewportWidth = 800, - viewportHeight = 600, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, ): NpcDialogLayout | null { if (!this.npcMenu) return null const opts = this.npcMenu.options ?? [ @@ -2404,12 +2734,13 @@ export class WorldPanelsHud { const headerName = this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) : getLocalizedNpcName(this.npcMenu.npcName, this.lang) + const resolvedFont = font === undefined ? this.defaultFont : font return computeNpcDialogLayout( headerName, opts, this.npcMenu.x, this.npcMenu.y, - font, + resolvedFont, this.lang, viewportWidth, viewportHeight, @@ -2420,15 +2751,21 @@ export class WorldPanelsHud { logicalX: number, logicalY: number, font?: Pick | null, - viewportWidth = 800, - viewportHeight = 600, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, ): boolean { if (!this.npcMenu) return false if (this.npcMenu.talking) { - const boxW = 340 - const boxH = 156 - const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) - const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2))) + const boxW = NPC_SPEECH_BOX_W + const boxH = NPC_SPEECH_BOX_H + const bx = Math.max( + NPC_SPEECH_MIN_X, + Math.min(viewportWidth - boxW - NPC_SPEECH_MIN_X, Math.round(this.npcMenu.x - boxW / 2)), + ) + const by = Math.max( + NPC_SPEECH_MIN_Y, + Math.min(viewportHeight - NPC_SPEECH_BOTTOM_PAD_Y - boxH, Math.round(this.npcMenu.y - boxH / 2)), + ) return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH } const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight) @@ -2445,8 +2782,8 @@ export class WorldPanelsHud { logicalX: number, logicalY: number, font?: Pick | null, - viewportWidth = 800, - viewportHeight = 600, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, ): void { if (!this.npcMenu || this.npcMenu.talking) return const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight) @@ -2469,8 +2806,8 @@ export class WorldPanelsHud { onCancel: () => void }, font?: Pick | null, - viewportWidth = 800, - viewportHeight = 600, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, ): boolean { if (!this.npcMenu) return false if (this.npcMenu.talking) { @@ -2486,12 +2823,13 @@ export class WorldPanelsHud { const headerName = this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) : getLocalizedNpcName(this.npcMenu.npcName, this.lang) + const resolvedFont = font === undefined ? this.defaultFont : font const layout = computeNpcDialogLayout( headerName, opts, this.npcMenu.x, this.npcMenu.y, - font, + resolvedFont, this.lang, viewportWidth, viewportHeight, @@ -2596,22 +2934,31 @@ export class WorldPanelsHud { return true } - // Act Tabs (`Act I..V` at top of Quest / Waypoint panels: `oy + 8 .. oy + 40`) - if ((kind === 'quest' || kind === 'waypoint') && logicalY >= oy + 6 && logicalY <= oy + 42) { - const tabIdx = Math.min(5, Math.max(1, Math.floor((logicalX - (ox + 8)) / 61) + 1)) - this.selectedActTab = tabIdx - if (kind === 'quest') { - const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]! - if (this.selectedQuestIdx >= actQuests.length) { - this.selectedQuestIdx = 0 + // Act Tabs (`Act I..V` at top of Quest / Waypoint panels: `ox + 4 + i * 62`, `oy + 8 .. oy + 39` per ACT_TAB_LAYOUT) + if ( + (kind === 'quest' || kind === 'waypoint') && + logicalY >= oy + ACT_TAB_LAYOUT.topYOffset && + logicalY <= oy + ACT_TAB_LAYOUT.topYOffset + ACT_TAB_LAYOUT.height + ) { + for (let i = 0; i < ACT_TAB_LAYOUT.count; i++) { + const tx = ox + ACT_TAB_LAYOUT.startXOffset + i * ACT_TAB_LAYOUT.strideX + const maxX = i === ACT_TAB_LAYOUT.count - 1 ? tx + ACT_TAB_LAYOUT.frameWidth : tx + ACT_TAB_LAYOUT.strideX + if (logicalX >= tx && logicalX < maxX) { + this.selectedActTab = i + 1 + if (kind === 'quest') { + const actQuests = getActQuests(this.selectedActTab) + if (this.selectedQuestIdx >= actQuests.length) { + this.selectedQuestIdx = 0 + } + } + return true } } - return true } if (kind === 'quest') { // 6 Quest Sockets (`2 rows × 3 cols`) - const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]! + const actQuests = getActQuests(this.selectedActTab) for (let i = 0; i < actQuests.length; i++) { const col = i % 3 const row = Math.floor(i / 3) @@ -2623,9 +2970,9 @@ export class WorldPanelsHud { } } } else if (kind === 'waypoint') { - const list = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]! + const list = getActWaypoints(this.selectedActTab) for (let i = 0; i < list.length; i++) { - const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) + const rowY = getWaypointRowOffsetY(i) const wy = oy + rowY if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) { const wp = list[i]! @@ -2903,14 +3250,23 @@ export class WorldPanelsHud { if (kind === 'quest') { if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy) - this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg) + this.drawActTabs( + ctx, + ox, + oy, + font, + assets.questTabsImg !== undefined ? assets.questTabsImg : this.getQuestTabsImage(), + ) - const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]! + const quests = getActQuests(this.selectedActTab) if (this.selectedQuestIdx >= quests.length) { this.selectedQuestIdx = 0 } - const atlas = assets.questsAtlasImg ?? this.getQuestsAtlasImage() + const atlas = this.assertValidUiImage( + assets.questsAtlasImg !== undefined ? assets.questsAtlasImg : this.getQuestsAtlasImage(), + 'questsAtlasImg', + ) for (let i = 0; i < 6; i++) { // In Act 4, sockets 3, 4, 5 (indices 3, 4, 5) are unassigned @@ -2927,36 +3283,13 @@ export class WorldPanelsHud { const rectKey = status === 'completed' ? `questdone_${q.id}` : q.id const rect = BAKED_UI_MANIFEST.questRects[rectKey] + if (!rect) { + throw new Error(`Missing quest icon sprite rect: ${rectKey}`) + } const ix = sx + 4 const iy = sy + 1 - if (atlas && rect) { - ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, ix, iy, rect.w, rect.h) - } else { - ctx.fillStyle = status === 'completed' ? 'rgba(42, 36, 22, 0.85)' : 'rgba(18, 16, 14, 0.88)' - ctx.fillRect(sx, sy, 80, 88) - font.drawText(ctx, `Q${i + 1}`, sx + 40, sy + 32, { - font: 'font16', - color: status === 'completed' ? 'gold' : 'white', - align: 'center', - }) - font.drawText(ctx, isEn ? q.titleEn : q.titleZh, sx + 40, sy + 56, { - font: 'font8', - color: status === 'completed' ? 'gold' : 'tan', - align: 'center', - }) - font.drawText( - ctx, - status === 'completed' ? (isEn ? 'Completed' : '已完成') : (isEn ? 'Active' : '进行中'), - sx + 40, - sy + 76, - { - font: 'font8', - color: status === 'completed' ? 'green' : 'blue', - align: 'center', - }, - ) - } + ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, ix, iy, rect.w, rect.h) ctx.strokeStyle = selected ? '#e8c26b' : status === 'completed' ? '#6c5838' : '#3a3224' ctx.lineWidth = selected ? 2 : 1 @@ -2975,35 +3308,35 @@ export class WorldPanelsHud { } } else if (kind === 'waypoint') { if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy) - this.drawActTabs(ctx, ox, oy, font, assets.waypointTabsImg) - const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]! + this.drawActTabs( + ctx, + ox, + oy, + font, + assets.waypointTabsImg !== undefined ? assets.waypointTabsImg : this.getWaypointTabsImage(), + ) + const wps = getActWaypoints(this.selectedActTab) for (let i = 0; i < wps.length; i++) { const wp = wps[i]! - const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) + const rowY = getWaypointRowOffsetY(i) const wy = oy + rowY const isHovered = this.hoveredWaypointIdx === i const isUnlocked = this.isWaypointUnlocked(wp) const isCurrent = isUnlocked && this.isCurrentWaypoint(wp) - ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)' - ctx.fillRect(ox + 17, wy, 281, 31) - ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32' - ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30) - if (isUnlocked) { + const waypointIconsImg = this.assertValidUiImage( + assets.waypointIconsImg !== undefined ? assets.waypointIconsImg : this.getWaypointIconsImage(), + 'waypointIconsImg', + ) const iconFrameIdx = isCurrent ? (isHovered ? 1 : 0) : (isHovered ? 4 : 3) const iconSx = iconFrameIdx * 30 - - if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) { - ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30) - } else { - ctx.fillStyle = isCurrent ? (isHovered ? '#78b4ff' : '#4888ff') : (isHovered ? '#f0d898' : '#c8aa6e') - ctx.fillRect(ox + 26, wy + 9, 12, 12) - } + ctx.drawImage(waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30) } - font.drawText(ctx, isEn ? wp.nameEn : wp.nameZh, ox + 80, wy + 21, { + const wpName = resolveWaypointName(wp.levelId, this.lang) + font.drawText(ctx, wpName, ox + 80, oy + getWaypointTextOffsetY(i), { font: 'fontexocet10', color: isUnlocked ? (isHovered ? 'white' : 'gold') : 'gray', }) @@ -3038,37 +3371,39 @@ export class WorldPanelsHud { } // Draw items stored in stash - const atlas = assets.itemsAtlasImg ?? null const playerCtx = this.getEffectivePlayerContext() + const hasCursorItem = Boolean((assets.inventory ?? this.activePlayerInventory)?.cursorItem) + const atlas = + this.stashItems.length > 0 + ? this.assertValidUiImage( + assets.itemsAtlasImg !== undefined ? assets.itemsAtlasImg : this.getItemsAtlasImage(), + 'itemsAtlasImg', + ) + : (assets.itemsAtlasImg ?? null) for (const placed of this.stashItems) { const gx = STASH_GRID_ORIGIN.x + placed.col * STASH_GRID_ORIGIN.cellPx const gy = STASH_GRID_ORIGIN.y + placed.row * STASH_GRID_ORIGIN.cellPx const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx + const isHovered = this.hoveredStashItem?.item.id === placed.item.id - ctx.fillStyle = - isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, playerCtx) - ? 'rgba(72, 18, 18, 0.50)' - : placed.item.quality === 'unique' - ? 'rgba(58, 42, 16, 0.45)' - : placed.item.quality === 'set' - ? 'rgba(16, 52, 22, 0.45)' - : 'rgba(20, 32, 54, 0.45)' + ctx.fillStyle = resolveGridItemBackgroundFill(placed.item, playerCtx, { + isHovered, + hasCursorItem, + }) ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) - if (atlas && rect) { - const scale = Math.min(gw / rect.w, gh / rect.h, 1) - const dw = Math.round(rect.w * scale) - const dh = Math.round(rect.h * scale) - const dx = gx + Math.round((gw - dw) / 2) - const dy = gy + Math.round((gh - dh) / 2) - ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) - } else { - ctx.fillStyle = - placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8' - ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) + if (!rect) { + throw new Error(`Missing item sprite rect for stash item: ${placed.item.code}`) } + const scale = Math.min(gw / rect.w, gh / rect.h, 1) + const dw = Math.round(rect.w * scale) + const dh = Math.round(rect.h * scale) + const dx = gx + Math.round((gw - dw) / 2) + const dy = gy + Math.round((gh - dh) / 2) + ctx.drawImage(atlas!, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) + drawItemSocketOverlay(ctx, placed.item, gx, gy, gw, gh, { itemsAtlasImg: atlas, itemRects: BAKED_UI_MANIFEST.itemRects, @@ -3088,37 +3423,39 @@ export class WorldPanelsHud { } // Draw items stored in Horadric Cube - const atlas = assets.itemsAtlasImg ?? null const playerCtx = this.getEffectivePlayerContext() + const hasCursorItem = Boolean((assets.inventory ?? this.activePlayerInventory)?.cursorItem) + const atlas = + this.cubeItems.length > 0 + ? this.assertValidUiImage( + assets.itemsAtlasImg !== undefined ? assets.itemsAtlasImg : this.getItemsAtlasImage(), + 'itemsAtlasImg', + ) + : (assets.itemsAtlasImg ?? null) for (const placed of this.cubeItems) { const gx = CUBE_GRID_ORIGIN.x + placed.col * CUBE_GRID_ORIGIN.cellPx const gy = CUBE_GRID_ORIGIN.y + placed.row * CUBE_GRID_ORIGIN.cellPx const gw = placed.item.invWidth * CUBE_GRID_ORIGIN.cellPx const gh = placed.item.invHeight * CUBE_GRID_ORIGIN.cellPx + const isHovered = this.hoveredCubeItem?.item.id === placed.item.id - ctx.fillStyle = - isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, playerCtx) - ? 'rgba(72, 18, 18, 0.50)' - : placed.item.quality === 'unique' - ? 'rgba(58, 42, 16, 0.45)' - : placed.item.quality === 'set' - ? 'rgba(16, 52, 22, 0.45)' - : 'rgba(20, 32, 54, 0.45)' + ctx.fillStyle = resolveGridItemBackgroundFill(placed.item, playerCtx, { + isHovered, + hasCursorItem, + }) ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) - if (atlas && rect) { - const scale = Math.min(gw / rect.w, gh / rect.h, 1) - const dw = Math.round(rect.w * scale) - const dh = Math.round(rect.h * scale) - const dx = gx + Math.round((gw - dw) / 2) - const dy = gy + Math.round((gh - dh) / 2) - ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) - } else { - ctx.fillStyle = - placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8' - ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) + if (!rect) { + throw new Error(`Missing item sprite rect for cube item: ${placed.item.code}`) } + const scale = Math.min(gw / rect.w, gh / rect.h, 1) + const dw = Math.round(rect.w * scale) + const dh = Math.round(rect.h * scale) + const dx = gx + Math.round((gw - dw) / 2) + const dy = gy + Math.round((gh - dh) / 2) + ctx.drawImage(atlas!, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) + drawItemSocketOverlay(ctx, placed.item, gx, gy, gw, gh, { itemsAtlasImg: atlas, itemRects: BAKED_UI_MANIFEST.itemRects, @@ -3142,37 +3479,38 @@ export class WorldPanelsHud { } // D2Client.dll `0x6fb49657..0x6fb49698`: Cube Transmute button (`miniconvert.dc6` frame 0 or 1 at `ox + 144, oy + 260`) - const miniConvertImg = assets.miniConvertImg ?? this.getMiniConvertImage() - if (miniConvertImg) { - const transmuteFrame = this.pressedCubeButton === 'transmute' ? 1 : 0 - ctx.drawImage( - miniConvertImg, - transmuteFrame * 32, - 0, - 32, - 32, - ox + CUBE_TRANSMUTE_BTN_BOUNDS.relX, - oy + CUBE_TRANSMUTE_BTN_BOUNDS.relY, - CUBE_TRANSMUTE_BTN_BOUNDS.w, - CUBE_TRANSMUTE_BTN_BOUNDS.h, - ) - } + const miniConvertImg = this.assertValidUiImage( + assets.miniConvertImg !== undefined ? assets.miniConvertImg : this.getMiniConvertImage(), + 'miniConvertImg', + ) + const transmuteFrame = this.pressedCubeButton === 'transmute' ? 1 : 0 + ctx.drawImage( + miniConvertImg, + transmuteFrame * 32, + 0, + 32, + 32, + ox + CUBE_TRANSMUTE_BTN_BOUNDS.relX, + oy + CUBE_TRANSMUTE_BTN_BOUNDS.relY, + CUBE_TRANSMUTE_BTN_BOUNDS.w, + CUBE_TRANSMUTE_BTN_BOUNDS.h, + ) // D2Client.dll `0x6fb49720..0x6fb497c8`: Centered framed hover tooltip (`改變`/`Transmute`, `關閉`/`Close` in Font16) if (this.hoveredCubeButton !== null) { const spec = this.hoveredCubeButton === 'transmute' ? CUBE_TRANSMUTE_BTN_BOUNDS : CUBE_CLOSE_BTN_BOUNDS - const hoverText = isEn ? spec.labelEn : spec.labelZh - const padX = 6 - const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2) - const boxH = 22 + const hoverText = requireTbl(spec.strIdx === 3341 ? 'strUiMenu2' : 'strClose', this.lang) + const padX = BUTTON_HOVER_BOX_PAD_X + const boxW = Math.max(BUTTON_HOVER_BOX_MIN_W, font.measureText(hoverText, 'font16') + padX * 2) + const boxH = BUTTON_HOVER_BOX_H const centerX = ox + (spec.tooltipCenterX - CUBE_PANEL_ORIGIN.x) const anchorY = oy + (spec.tooltipAnchorY - CUBE_PANEL_ORIGIN.y) const boxX = Math.round(centerX - boxW / 2) const boxY = anchorY - boxH - ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.fillStyle = STONE_BOX_BG_FILL ctx.fillRect(boxX, boxY, boxW, boxH) - font.drawText(ctx, hoverText, centerX, boxY + 17, { + font.drawText(ctx, hoverText, centerX, boxY + BUTTON_HOVER_TEXT_OFFSET_Y, { font: 'font16', color: 'white', align: 'center', @@ -3190,8 +3528,14 @@ export class WorldPanelsHud { ctx.drawImage(assets.vendorBgImg, ox, oy) // 2. D2Client.dll `0x6fb458a0..0x6fb45b58`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`) - const atlas = assets.itemsAtlasImg ?? null const placements = this.getActiveVendorPlacements() + const atlas = + placements.length > 0 + ? this.assertValidUiImage( + assets.itemsAtlasImg !== undefined ? assets.itemsAtlasImg : this.getItemsAtlasImage(), + 'itemsAtlasImg', + ) + : (assets.itemsAtlasImg ?? null) const playerCtx = this.getEffectivePlayerContext() const hasCursorItem = Boolean((assets.inventory ?? this.activePlayerInventory)?.cursorItem) for (const placed of placements) { @@ -3209,17 +3553,15 @@ export class WorldPanelsHud { ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) - if (atlas && rect) { - const scale = Math.min(gw / rect.w, gh / rect.h, 1) - const dw = Math.round(rect.w * scale) - const dh = Math.round(rect.h * scale) - const dx = gx + Math.round((gw - dw) / 2) - const dy = gy + Math.round((gh - dh) / 2) - ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) - } else { - ctx.fillStyle = placed.item.quality === 'magic' ? '#4850b8' : '#786850' - ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4) + if (!rect) { + throw new Error(`Missing item sprite rect for vendor item: ${placed.item.code}`) } + const scale = Math.min(gw / rect.w, gh / rect.h, 1) + const dw = Math.round(rect.w * scale) + const dh = Math.round(rect.h * scale) + const dx = gx + Math.round((gw - dw) / 2) + const dy = gy + Math.round((gh - dh) / 2) + ctx.drawImage(atlas!, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh) } // 3. D2Client.dll `0x6fb45df1–0x6fb45e51`: 4 button slots (`buysellbtn.dc6` at `ox + tableX - 1`, `oy + 385`) @@ -3238,7 +3580,7 @@ export class WorldPanelsHud { const frameIdx = selected ? tab.index : tab.index + 4 ctx.drawImage(assets.vendorTabsImg, frameIdx * 79, 0, 79, 31, ox + tab.xOffset, oy, 79, 31) if (this.vendorMode === 'trade') { - font.drawText(ctx, isEn ? tab.labelEn : tab.labelZh, ox + tab.centerX, oy + tab.textY, { + font.drawText(ctx, requireTbl(tab.tblKey, this.lang), ox + tab.centerX, oy + tab.textY, { font: 'font16', color: selected ? 'gold' : 'white', align: 'center', @@ -3248,11 +3590,17 @@ export class WorldPanelsHud { // 5. Status toast text inside `buysell.dc6` bottom-left long box (`relX 17..200, relY 359..375`) if (this.vendorStatusMsg && performance.now() < this.vendorStatusMsg.untilMs) { - font.drawText(ctx, this.vendorStatusMsg.text, ox + 109, oy + 371, { - font: 'font8', - color: this.vendorStatusMsg.color, - align: 'center', - }) + font.drawText( + ctx, + this.vendorStatusMsg.text, + ox + VENDOR_STATUS_TEXT_POS.relX, + oy + VENDOR_STATUS_TEXT_POS.relY, + { + font: 'font8', + color: this.vendorStatusMsg.color, + align: 'center', + }, + ) } // 6. D2Client.dll `0x6fb3f260` -> D2Win #10085 (`0x6f8f33a0`): Left-anchored framed button hover text @@ -3264,17 +3612,19 @@ export class WorldPanelsHud { const hoverText = btnKind === 'repairAll' ? requireTbl('patchstring#95', this.lang).replace('%d', String(this.requireRepairAllCost())) - : isEn - ? spec.labelEn - : spec.labelZh - const padX = 6 - const boxW = Math.max(24, font.measureText(hoverText, 'font16') + padX * 2) - const boxH = 22 + : spec.tblKey + ? requireTbl(spec.tblKey, this.lang) + : isEn + ? spec.labelEn + : spec.labelZh + const padX = BUTTON_HOVER_BOX_PAD_X + const boxW = Math.max(BUTTON_HOVER_BOX_MIN_W, font.measureText(hoverText, 'font16') + padX * 2) + const boxH = BUTTON_HOVER_BOX_H const boxX = ox + VENDOR_BUTTON_TABLE_X[slot]! - const boxY = oy + 380 - boxH - ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + const boxY = oy + VENDOR_BUTTON_HOVER_ANCHOR_Y - boxH + ctx.fillStyle = STONE_BOX_BG_FILL ctx.fillRect(boxX, boxY, boxW, boxH) - font.drawText(ctx, hoverText, boxX + padX, boxY + 17, { + font.drawText(ctx, hoverText, boxX + padX, boxY + BUTTON_HOVER_TEXT_OFFSET_Y, { font: 'font16', color: 'white', align: 'left', @@ -3308,19 +3658,28 @@ export class WorldPanelsHud { boxPiecesImg?: HTMLImageElement | null focus16Img?: HTMLImageElement | null }, - viewportWidth = 800, - viewportHeight = 600, + viewportWidth = this.viewportWidth, + viewportHeight = this.viewportHeight, ): void { if (!this.npcMenu) return - const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage() + const boxPiecesImg = this.assertValidUiImage( + assets && 'boxPiecesImg' in assets ? assets.boxPiecesImg : this.getBoxPiecesImage(), + 'boxPiecesImg', + ) if (this.npcMenu.talking) { const talkHeaderName = this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) : getLocalizedNpcName(this.npcMenu.npcName, this.lang) - const boxW = 340 - const boxH = 156 - const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) - const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2))) + const boxW = NPC_SPEECH_BOX_W + const boxH = NPC_SPEECH_BOX_H + const bx = Math.max( + NPC_SPEECH_MIN_X, + Math.min(viewportWidth - boxW - NPC_SPEECH_MIN_X, Math.round(this.npcMenu.x - boxW / 2)), + ) + const by = Math.max( + NPC_SPEECH_MIN_Y, + Math.min(viewportHeight - NPC_SPEECH_BOTTOM_PAD_Y - boxH, Math.round(this.npcMenu.y - boxH / 2)), + ) drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg) @@ -3640,8 +3999,12 @@ export class WorldPanelsHud { screenWidth = 800, screenHeight = 600, ): void { - const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage() - const textSlidImg = assets?.textSlidImg ?? this.getTextSlidImage() + const boxPiecesImg = this.assertValidUiImage( + assets && 'boxPiecesImg' in assets ? assets.boxPiecesImg : this.getBoxPiecesImage(), + 'boxPiecesImg', + ) + const textSlidImg = + assets && 'textSlidImg' in assets ? assets.textSlidImg : this.getTextSlidImage() const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2) const dialogY = Math.floor(screenHeight / 2) - 195 @@ -3662,14 +4025,7 @@ export class WorldPanelsHud { } // 1. Outer carved stone frame (490x350) - if (boxPiecesImg) { - drawStoneBoxFrame(ctx, dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT, boxPiecesImg) - } else { - ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' - ctx.fillRect(dialogX + 1, dialogY, Math.max(0, HIRE_DIALOG_WIDTH - 2), Math.max(0, HIRE_DIALOG_HEIGHT - 2)) - ctx.strokeStyle = '#6c5838' - ctx.strokeRect(dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT) - } + drawStoneBoxFrame(ctx, dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT, boxPiecesImg) // 2. Title Line: "Your Gold: %d Hire which Mercenary?" (ItemDesc1s) const totalGold = (this.playerGold ?? 0) + (this.stashGold ?? 0) @@ -3749,44 +4105,32 @@ export class WorldPanelsHud { maxScroll: number, textSlidImg: HTMLImageElement | null | undefined, ): void { - if (textSlidImg && textSlidImg.complete && textSlidImg.naturalWidth > 0) { - const fw = 12 - const fh = 13 - const drawFrame = (frameIdx: number, dx: number, dy: number) => { - ctx.drawImage(textSlidImg, frameIdx * fw, 0, fw, fh, dx, dy, fw, fh) - } + const validTextSlidImg = this.assertValidUiImage(textSlidImg, 'textSlidImg') + const fw = 12 + const fh = 13 + const drawFrame = (frameIdx: number, dx: number, dy: number) => { + ctx.drawImage(validTextSlidImg, frameIdx * fw, 0, fw, fh, dx, dy, fw, fh) + } - // Up arrow: frame 9 - drawFrame(9, sliderX, sliderY) + // Up arrow: frame 9 + drawFrame(9, sliderX, sliderY) - // 21 track segments: frame 13 - for (let i = 0; i < HIRE_TRACK_SEGMENTS; i++) { - drawFrame(13, sliderX, sliderY + 12 + i * 12) - } + // 21 track segments: frame 13 + for (let i = 0; i < HIRE_TRACK_SEGMENTS; i++) { + drawFrame(13, sliderX, sliderY + 12 + i * 12) + } - // Down arrow: frame 8 - drawFrame(8, sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12) + // Down arrow: frame 8 + drawFrame(8, sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12) - // Thumb slider - if (maxScroll > 0) { - const thumbMaxSeg = HIRE_TRACK_SEGMENTS - 3 - const thumbStartSeg = Math.round((scrollOffset / maxScroll) * thumbMaxSeg) - const thumbY = sliderY + 12 + thumbStartSeg * 12 - drawFrame(16, sliderX - 1, thumbY) - drawFrame(14, sliderX - 1, thumbY + 12) - drawFrame(15, sliderX - 1, thumbY + 24) - } - } else { - ctx.fillStyle = '#222222' - ctx.fillRect(sliderX, sliderY, 12, 12 + HIRE_TRACK_SEGMENTS * 12 + 13) - ctx.fillStyle = '#555555' - ctx.fillRect(sliderX, sliderY, 12, 12) - ctx.fillRect(sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12, 12, 12) - if (maxScroll > 0) { - const thumbY = sliderY + 12 + Math.round((scrollOffset / maxScroll) * (HIRE_TRACK_SEGMENTS * 12 - 36)) - ctx.fillStyle = '#888888' - ctx.fillRect(sliderX, thumbY, 12, 36) - } + // Thumb slider + if (maxScroll > 0) { + const thumbMaxSeg = HIRE_TRACK_SEGMENTS - 3 + const thumbStartSeg = Math.round((scrollOffset / maxScroll) * thumbMaxSeg) + const thumbY = sliderY + 12 + thumbStartSeg * 12 + drawFrame(16, sliderX - 1, thumbY) + drawFrame(14, sliderX - 1, thumbY + 12) + drawFrame(15, sliderX - 1, thumbY + 24) } } @@ -3797,6 +4141,7 @@ export class WorldPanelsHud { screenWidth = 800, screenHeight = 600, ): void { + const validBoxPiecesImg = this.assertValidUiImage(boxPiecesImg, 'boxPiecesImg') const confirmW = HIRE_CONFIRM_WIDTH const confirmH = HIRE_CONFIRM_HEIGHT const cx = Math.floor((screenWidth - confirmW) / 2) @@ -3822,14 +4167,7 @@ export class WorldPanelsHud { ctx.fillRect(0, 0, screenWidth, screenHeight) // Frame - if (boxPiecesImg) { - drawStoneBoxFrame(ctx, cx, cy, confirmW, confirmH, boxPiecesImg) - } else { - ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' - ctx.fillRect(cx + 1, cy, Math.max(0, confirmW - 2), Math.max(0, confirmH - 2)) - ctx.strokeStyle = '#6c5838' - ctx.strokeRect(cx, cy, confirmW, confirmH) - } + drawStoneBoxFrame(ctx, cx, cy, confirmW, confirmH, validBoxPiecesImg) // Warning text: "This Mercenary will replace your current one." (VerifyTransaction9) const warnText = lookupTbl('VerifyTransaction9', this.lang) ?? (this.lang === 'ENG' ? 'This Mercenary will replace your current one.' : '這個傭兵會換掉前一位。') @@ -3854,35 +4192,27 @@ export class WorldPanelsHud { ctx: CanvasRenderingContext2D, ox: number, oy: number, - font: D2FontRenderer, + _font: D2FontRenderer, tabsImg?: HTMLImageElement | null, ): void { - if (tabsImg && tabsImg.complete && tabsImg.naturalWidth > 0) { - for (let i = 0; i < 5; i++) { - const act = i + 1 - const active = this.selectedActTab === act - const frameIdx = active ? i * 2 : i * 2 + 1 - const sx = frameIdx * 63 - const tx = ox + 4 + i * 62 - ctx.drawImage(tabsImg, sx, 0, 63, 31, tx, oy + 8, 63, 31) - } - return - } - - const numerals = ['I', 'II', 'III', 'IV', 'V'] as const - for (let i = 0; i < 5; i++) { + const validTabsImg = this.assertValidUiImage(tabsImg, 'actTabsImg') + for (let i = 0; i < ACT_TAB_LAYOUT.count; i++) { const act = i + 1 const active = this.selectedActTab === act - const tx = ox + 8 + i * 61 - ctx.fillStyle = active ? '#32281c' : '#181410' - ctx.fillRect(tx, oy + 8, 58, 28) - ctx.strokeStyle = active ? '#e8c26b' : '#584a34' - ctx.strokeRect(tx + 0.5, oy + 8.5, 57, 27) - font.drawText(ctx, `Act ${numerals[i]!}`, tx + 29, oy + 27, { - font: 'fontexocet10', - color: active ? 'gold' : 'tan', - align: 'center', - }) + const frameIdx = active ? i * 2 : i * 2 + 1 + const sx = frameIdx * ACT_TAB_LAYOUT.frameWidth + const tx = ox + ACT_TAB_LAYOUT.startXOffset + i * ACT_TAB_LAYOUT.strideX + ctx.drawImage( + validTabsImg, + sx, + 0, + ACT_TAB_LAYOUT.frameWidth, + ACT_TAB_LAYOUT.height, + tx, + oy + ACT_TAB_LAYOUT.topYOffset, + ACT_TAB_LAYOUT.frameWidth, + ACT_TAB_LAYOUT.height, + ) } } @@ -4059,7 +4389,10 @@ export class WorldPanelsHud { } /** - * Draw Canonical Diablo II Item Hover Tooltip (`showitems.cpp`). + * Draw Canonical Diablo II Item Hover Tooltip (`showitems.cpp` `0x6fb47fd8` & `0x6fb49fe0`). + * - All lines use uniform `font16`. + * - Weapon speed line appears AFTER `Required Dexterity`, `Required Strength`, and `Required Level`. + * - Tight box sizing (`maxW + 16`, `lines.length * 16 + 16`) with `STONE_BOX_BG_FILL` (`rgba(0, 0, 0, 0.85)`) and NO border stroke. */ drawItemTooltip( ctx: CanvasRenderingContext2D, @@ -4072,7 +4405,7 @@ export class WorldPanelsHud { if (!hover) return const { item } = hover const isEn = this.lang === 'ENG' - const lines: { text: string; color: D2ColorCode; font: 'fontexocet10' | 'font8' }[] = [] + const lines: { text: string; color: D2ColorCode; font: 'font16' }[] = [] const titleColor: D2ColorCode = item.quality === 'unique' || item.quality === 'rune' @@ -4108,32 +4441,32 @@ export class WorldPanelsHud { localizeStarterTooltipLine(item.nameZh, true) : (item.baseNameZh ? resolveHeaderZh(item.baseNameZh.split(' · ')[0]!) : undefined) || resolveHeaderZh(item.nameZh) - lines.push({ text: maskedBaseHeader, color: titleColor, font: 'fontexocet10' }) + lines.push({ text: maskedBaseHeader, color: titleColor, font: 'font16' }) } else { const headerText = isEn ? (item.name && !/[\u4e00-\u9fff]/.test(item.name) ? item.name : localizeStarterTooltipLine(item.nameZh, true)) : resolveHeaderZh(item.nameZh) - lines.push({ text: headerText, color: titleColor, font: 'fontexocet10' }) + lines.push({ text: headerText, color: titleColor, font: 'font16' }) if (item.runewordRunes) { - lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' }) + lines.push({ text: item.runewordRunes, color: 'gold', font: 'font16' }) } const baseLine = resolveBaseName() if (baseLine) { - lines.push({ text: baseLine, color: 'white', font: 'font8' }) + lines.push({ text: baseLine, color: 'white', font: 'font16' }) } } // Vendor Transaction Cost Line (`ITEMS_CalculateTransactionCost` at 0x6FD9B1C0) // Prices come from HudModel's injected VendorPriceFn (buyCost / sellCost / repairCost on the UI item). if (item.buyCost !== undefined) { - lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font8' }) + lines.push({ text: `${requireTbl('cost', this.lang)}${item.buyCost}`, color: 'white', font: 'font16' }) } else if (vendorOpen && this.activeVendorDescriptor) { if (this.repairCursorMode && this.activeVendorDescriptor.canRepair) { if (item.repairCost !== undefined && item.repairCost > 0) { - lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font8' }) + lines.push({ text: `${requireTbl('Repair', this.lang)}${item.repairCost}`, color: 'white', font: 'font16' }) } } else if (item.sellCost !== undefined) { - lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font8' }) + lines.push({ text: `${requireTbl('Sell', this.lang)}${item.sellCost}`, color: 'white', font: 'font16' }) } } @@ -4142,7 +4475,7 @@ export class WorldPanelsHud { lines.push({ text: (isEn ? 'Defense: ' : '防御: ') + item.defense, color: 'white', - font: 'font8', + font: 'font16', }) } if (item.damage !== undefined || item.damageEn !== undefined) { @@ -4150,11 +4483,11 @@ export class WorldPanelsHud { const rawDmg = item.damageEn ?? item.damage ?? '' const cleanDmg = localizeStarterTooltipLine(rawDmg, true) const dmgText = /Damage/i.test(cleanDmg) ? cleanDmg : `One-Hand Damage: ${cleanDmg}` - lines.push({ text: dmgText, color: 'white', font: 'font8' }) + lines.push({ text: dmgText, color: 'white', font: 'font16' }) } else { const rawDmg = item.damage ?? '' const dmgText = rawDmg.includes('伤害') ? rawDmg : ('单手伤害: ' + rawDmg) - lines.push({ text: dmgText, color: 'white', font: 'font8' }) + lines.push({ text: dmgText, color: 'white', font: 'font16' }) } } if (item.durability !== undefined) { @@ -4163,15 +4496,9 @@ export class WorldPanelsHud { ? `Durability: ${item.durability.current} of ${item.durability.max}` : '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max, color: 'white', - font: 'font8', + font: 'font16', }) } - const speedLine = isEn - ? (item.speedTextEn ?? (item.speedText ? localizeStarterTooltipLine(item.speedText, true) : undefined)) - : (item.speedText ? localizeStarterTooltipLine(item.speedText, false) : undefined) - if (speedLine) { - lines.push({ text: speedLine, color: 'white', font: 'font8' }) - } const playerCtx = this.getEffectivePlayerContext() const effectiveReqClass = getEffectiveItemReqClass(item) if (!unid && effectiveReqClass !== undefined) { @@ -4198,7 +4525,7 @@ export class WorldPanelsHud { lines.push({ text: classText, color: playerCtx.classCode && playerCtx.classCode !== effectiveReqClass ? 'red' : 'white', - font: 'font8', + font: 'font16', }) } } @@ -4206,14 +4533,14 @@ export class WorldPanelsHud { lines.push({ text: (isEn ? 'Required Dexterity: ' : '需要敏捷: ') + item.reqDex, color: playerCtx.dex < item.reqDex ? 'red' : 'white', - font: 'font8', + font: 'font16', }) } if (item.reqStr !== undefined) { lines.push({ text: (isEn ? 'Required Strength: ' : '需要力量: ') + item.reqStr, color: playerCtx.str < item.reqStr ? 'red' : 'white', - font: 'font8', + font: 'font16', }) } const displayReqLevel = unid @@ -4225,23 +4552,30 @@ export class WorldPanelsHud { lines.push({ text: (isEn ? 'Required Level: ' : '需要等级: ') + displayReqLevel, color: playerCtx.level < displayReqLevel ? 'red' : 'white', - font: 'font8', + font: 'font16', }) } + // Weapon Speed Line appears AFTER Required Dexterity / Strength / Level per `showitems.cpp` `0x6fb49fe0` + const speedLine = isEn + ? (item.speedTextEn ?? (item.speedText ? localizeStarterTooltipLine(item.speedText, true) : undefined)) + : (item.speedText ? localizeStarterTooltipLine(item.speedText, false) : undefined) + if (speedLine) { + lines.push({ text: speedLine, color: 'white', font: 'font16' }) + } if (unid) { if (item.isGambleMystery) { - lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font8' }) + lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font16' }) } else { - lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font8' }) + lines.push({ text: isEn ? 'Unidentified' : '未辨识', color: 'red', font: 'font16' }) if (item.allowedSlots.length > 0) { - lines.push({ text: isEn ? 'Cannot be Equipped' : '无法使用', color: 'red', font: 'font8' }) + lines.push({ text: isEn ? 'Cannot be Equipped' : '无法使用', color: 'red', font: 'font16' }) } if (item.ethereal) { lines.push({ text: isEn ? 'Ethereal (Cannot be Repaired)' : '无形的 (无法修复)', color: 'blue', - font: 'font8', + font: 'font16', }) } } @@ -4249,17 +4583,17 @@ export class WorldPanelsHud { // Magic Affixes & Socketable Instructions const socketableInfo = item.code ? getSocketableInfo(item.code) : undefined if (isEn && socketableInfo && !item.statsEn) { - lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white', font: 'font8' }) - lines.push({ text: `Weapons: ${socketableInfo.weaponsEn}`, color: 'white', font: 'font8' }) - lines.push({ text: `Armor: ${socketableInfo.armorEn}`, color: 'white', font: 'font8' }) - lines.push({ text: `Helms: ${socketableInfo.helmsEn}`, color: 'white', font: 'font8' }) - lines.push({ text: `Shields: ${socketableInfo.shieldsEn}`, color: 'white', font: 'font8' }) + lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white', font: 'font16' }) + lines.push({ text: `Weapons: ${socketableInfo.weaponsEn}`, color: 'white', font: 'font16' }) + lines.push({ text: `Armor: ${socketableInfo.armorEn}`, color: 'white', font: 'font16' }) + lines.push({ text: `Helms: ${socketableInfo.helmsEn}`, color: 'white', font: 'font16' }) + lines.push({ text: `Shields: ${socketableInfo.shieldsEn}`, color: 'white', font: 'font16' }) } else if (!isEn && socketableInfo && item.stats.length === 0) { - lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white', font: 'font8' }) - lines.push({ text: `武器: ${socketableInfo.weaponsZh}`, color: 'white', font: 'font8' }) - lines.push({ text: `盔甲: ${socketableInfo.armorZh}`, color: 'white', font: 'font8' }) - lines.push({ text: `头盔: ${socketableInfo.helmsZh}`, color: 'white', font: 'font8' }) - lines.push({ text: `盾牌: ${socketableInfo.shieldsZh}`, color: 'white', font: 'font8' }) + lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white', font: 'font16' }) + lines.push({ text: `武器: ${socketableInfo.weaponsZh}`, color: 'white', font: 'font16' }) + lines.push({ text: `盔甲: ${socketableInfo.armorZh}`, color: 'white', font: 'font16' }) + lines.push({ text: `头盔: ${socketableInfo.helmsZh}`, color: 'white', font: 'font16' }) + lines.push({ text: `盾牌: ${socketableInfo.shieldsZh}`, color: 'white', font: 'font16' }) } else { const activeStats = isEn ? (item.statsEn ?? item.stats) : item.stats const isJewel = @@ -4277,14 +4611,14 @@ export class WorldPanelsHud { lines.push({ text: isEn ? 'Can be Inserted into Socketed Items' : '可镶嵌进有凹槽的装备', color: 'white', - font: 'font8', + font: 'font16', }) } for (const st of activeStats) { lines.push({ text: localizeStarterTooltipLine(st.text, isEn), color: st.color ?? 'blue', - font: 'font8', + font: 'font16', }) } } @@ -4296,7 +4630,7 @@ export class WorldPanelsHud { lines.push({ text: isEn ? 'Ethereal (Cannot be Repaired)' : '无形的 (无法修复)', color: 'blue', - font: 'font8', + font: 'font16', }) } @@ -4309,7 +4643,7 @@ export class WorldPanelsHud { lines.push({ text: isEn ? `Socketed (${item.sockets})` : '凹槽 (' + item.sockets + ')', color: 'blue', - font: 'font8', + font: 'font16', }) } @@ -4320,7 +4654,7 @@ export class WorldPanelsHud { lines.push({ text: localizeStarterTooltipLine(piece, isEn), color: 'gray', - font: 'font8', + font: 'font16', }) } } @@ -4330,16 +4664,16 @@ export class WorldPanelsHud { lines.push({ text: localizeStarterTooltipLine(bonus.text, isEn), color: bonus.color ?? 'green', - font: 'font8', + font: 'font16', }) } } } - // Viewport Dimensions & Dynamic Vertical Flip / Clamping - const lineH = 17, padX = 16, padY = 12 + // Viewport Dimensions & Dynamic Vertical Flip / Clamping (`showitems.cpp` `0x6fb47fd8`) + const lineH = 16, padX = 8, padY = 8 const maxW = Math.max(...lines.map(l => font.measureText(l.text, l.font))) - const boxW = Math.max(190, maxW + padX * 2) + const boxW = maxW + padX * 2 const boxH = lines.length * lineH + padY * 2 const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8 @@ -4349,11 +4683,9 @@ export class WorldPanelsHud { const by = Math.max(topMargin, Math.min(viewportHeight - boxH - 8, rawBy)) this.lastTooltipRect = { bx, by, boxW, boxH } - // Rendering - ctx.fillStyle = 'rgba(4, 4, 4, 0.94)' + // Rendering: Translucent black background (`STONE_BOX_BG_FILL`) with NO border stroke + ctx.fillStyle = STONE_BOX_BG_FILL ctx.fillRect(bx, by, boxW, boxH) - ctx.strokeStyle = '#8c7244' - ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1) lines.forEach((l, idx) => { font.drawText(ctx, l.text, bx + Math.round(boxW / 2), by + padY + 12 + idx * lineH, { @@ -4365,29 +4697,16 @@ export class WorldPanelsHud { } /** - * Draw Area Entry Gothic Banner (`Entering: ` / `进入:`). + * Non-canonical `"Entering: "` banner removed per Diablo II v1.13c parity (Issue #673). + * Kept as a no-op method for caller compatibility with `hud-manager.ts`. */ drawAreaBanner( - ctx: CanvasRenderingContext2D, - nowMs: number, - font: D2FontRenderer, - viewportWidth = 800, + _ctx: CanvasRenderingContext2D, + _nowMs?: number, + _font?: D2FontRenderer, + _viewportWidth = 800, ): void { - if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return - const remain = this.areaBanner.untilMs - nowMs - const alpha = remain < 800 ? remain / 800 : 1 - ctx.save() - ctx.globalAlpha = Math.max(0, Math.min(1, alpha)) - const bannerText = - this.lang === 'ENG' - ? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}` - : `进入:${this.areaBanner.titleZh}` - font.drawText(ctx, bannerText, Math.round(viewportWidth / 2), 142, { - font: 'font30', - color: 'gold', - align: 'center', - }) - ctx.restore() + return } /** @@ -4450,48 +4769,8 @@ export class WorldPanelsHud { } } - private resolvePartyActionCode(member: any): number { - const rawRel = member?.relationship - const rawState = member?.partyState - const partyId = typeof member?.partyId === 'number' ? member.partyId : 0xffff - const hasParty = partyId !== 0xffff && partyId > 0 - if ( - rawState === 'invited_me' || - rawState === 'invited_by' || - rawRel === 'invited_me' || - rawRel === 'invited_by' || - rawRel === 2 || - rawState === 2 || - rawRel === 0x08 || - rawState === 0x08 - ) { - return 0x08 - } - if ( - rawState === 'invited_by_me' || - rawState === 'invited' || - rawRel === 'invited_by_me' || - rawRel === 'invited' || - rawRel === 0x07 || - rawState === 0x07 || - ((rawRel === 1 || rawState === 1) && !hasParty) - ) { - return 0x07 - } - if ( - rawState === 'in_party' || - rawState === 'party' || - rawRel === 'in_party' || - rawRel === 'party' || - rawRel === 3 || - rawState === 3 || - rawRel === 0x09 || - rawState === 0x09 || - (hasParty && rawRel !== 0 && rawState !== 0) - ) { - return 0x09 - } - return 0x06 + resolvePartyActionCode(member: any): 0x06 | 0x07 | 0x08 | 0x09 { + return resolvePartyActionCode(member) } /** @@ -5046,6 +5325,45 @@ export class WorldPanelsHud { } } +/** + * Resolve canonical D2GS 0x5E party action code (`D2Client.dll` party roster panel): + * - `0x06`: Invite player to party (`0` / `'none'` / `0x06`) + * - `0x07`: Cancel party invite sent by us (`1` / `'invited'` / `'invited_by_me'` / `0x07`) + * - `0x08`: Accept party invite sent to us (`2` / `'invited_me'` / `'invited_by'` / `0x08`) + * - `0x09`: Leave current party (`3` / `'in_party'` / `'party'` / `0x09`) + * + * Throws an explicit Error on null/undefined member or unknown/missing relationship/partyState + * instead of silently returning `0x06`. + */ +export function resolvePartyActionCode( + member: + | { + readonly relationship?: number | string | undefined + readonly partyState?: number | string | undefined + readonly partyId?: number | undefined + } + | null + | undefined, +): 0x06 | 0x07 | 0x08 | 0x09 { + if (!member || typeof member !== 'object') { + throw new Error('resolvePartyActionCode requires a valid party member descriptor') + } + const state = member.partyState !== undefined ? member.partyState : member.relationship + if (state === 0x06 || state === 0 || state === 'none' || state === 'hostile') { + return 0x06 + } + if (state === 0x07 || state === 1 || state === 'invited' || state === 'invited_by_me') { + return 0x07 + } + if (state === 0x08 || state === 2 || state === 'invited_me' || state === 'invited_by') { + return 0x08 + } + if (state === 0x09 || state === 3 || state === 'in_party' || state === 'party') { + return 0x09 + } + throw new Error(`resolvePartyActionCode: unrecognized party relationship/state ${JSON.stringify(state)}`) +} + export type EscapeMenuRowIndex = 0 | 1 | 2 export type EscapeMenuAction = 'options' | 'saveAndExit' | 'returnToGame' diff --git a/src/common/units/npc-table.ts b/src/common/units/npc-table.ts index b5e9f0c..a7c032e 100644 --- a/src/common/units/npc-table.ts +++ b/src/common/units/npc-table.ts @@ -329,7 +329,9 @@ export interface TownNpcServiceDescriptor { readonly canGamble: boolean; readonly canHeal: boolean; readonly canIdentify: boolean; + readonly canTalk?: boolean | undefined; readonly canHire?: boolean | undefined; + readonly hireBeforeTrade?: boolean | undefined; readonly travelActions?: readonly ('travel_east' | 'travel_west' | 'travel_harrogath')[] | undefined; readonly questRewardActions?: readonly ('imbue' | 'add_sockets' | 'personalize')[] | undefined; readonly greetingKey: string; @@ -663,6 +665,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [ canHeal: false, canIdentify: false, canHire: true, + hireBeforeTrade: true, greetingLines: [ 'Greetings. I am Asheara, commander of the Iron Wolves.', 'Browse my weapons and armor if you are heading into the jungle.', @@ -709,6 +712,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [ canGamble: false, canHeal: false, canIdentify: false, + canTalk: false, greetingLines: [ 'Keep your weapons ready and your armor mended, mortal.', ], @@ -730,6 +734,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [ canGamble: true, canHeal: true, canIdentify: false, + canTalk: false, greetingLines: [ 'The Light blesses you, hero. Let me restore your strength and offer my sacred wares.', ], diff --git a/tests/challenger-m2-level-names.test.ts b/tests/challenger-m2-level-names.test.ts index 80c6b36..65fcf27 100644 --- a/tests/challenger-m2-level-names.test.ts +++ b/tests/challenger-m2-level-names.test.ts @@ -431,23 +431,17 @@ describe('Adversarial Challenger Suite: M2 Localized Area Names (#147)', () => { // 7. HUD Area Banner Integration // -------------------------------------------------------------------------- describe('7. HUD Area Banner Integration', () => { - it('sets area banner in WorldPanelsHud with exact bilingual names and 3600ms duration', () => { + it('suppresses non-canonical area banner in WorldPanelsHud per 1.13c parity (Issue #673)', () => { const hud = new WorldPanelsHud() const now = 50000 const burial = getLevelNames(17) hud.showAreaBanner(burial.nameZh, burial.nameEn, now) - - expect(hud.areaBanner).toBeDefined() - expect(hud.areaBanner?.titleZh).toBe('埋骨之地') - expect(hud.areaBanner?.titleEn).toBe('Burial Grounds') - expect(hud.areaBanner?.untilMs).toBe(now + 3600) + expect(hud.areaBanner).toBeNull() const ancients = getLevelNames(118) hud.showAreaBanner(ancients.nameZh, ancients.nameEn, now + 1000) - expect(hud.areaBanner?.titleZh).toBe('远古之路') - expect(hud.areaBanner?.titleEn).toBe("The Ancients' Way") - expect(hud.areaBanner?.untilMs).toBe(now + 1000 + 3600) + expect(hud.areaBanner).toBeNull() }) }) }) diff --git a/tests/challenger-m3-npc-panels-stress.test.ts b/tests/challenger-m3-npc-panels-stress.test.ts index db80793..4619501 100644 --- a/tests/challenger-m3-npc-panels-stress.test.ts +++ b/tests/challenger-m3-npc-panels-stress.test.ts @@ -18,6 +18,7 @@ import { type NpcMenuContext, type NpcMenuOption, } from '../src/client/ui/world-panels.ts' +import { D2FontRenderer } from '../src/client/ui/font.ts' import { HudManager } from '../src/client/ui/hud-manager.ts' import { HudModel } from '../src/client/ui-model/hud-model.ts' import { ClientWorld } from '../src/client/world/client-world.ts' @@ -141,128 +142,68 @@ describe('Challenger 2 Empirical Stress Test Harness — NPC Dialogue & World Pa // ========================================================================= describe('Suite 2: Quest Prerequisite Gating & NPC Menu Options', () => { - it('2.1 Cain identify items gating: omit when 0 unid items, 100g when unrescued, 0g when rescued', () => { + it('2.1 Cain static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): always [Talk, Identify Items (3399), Cancel]', () => { const cain = getTownNpcDescriptorByName('cain1') expect(cain).toBeDefined() if (!cain) return - // DEFECT CHECK: Deckard Cain in Act 1 must have canIdentify: true expect( cain.canIdentify, 'cain1 (Act 1 Deckard Cain) must have canIdentify: true per Diablo II 1.13c canon', ).toBe(true) - // Case A: 0 unidentified items -> 'identify' option must NOT be present const ctxNoUnid: NpcMenuContext = { hasUnidentifiedItems: false, unidentifiedItemCount: 0, questFlags: new Uint8Array(48), } const optsNoUnid = buildNpcMenuOptions(cain, ctxNoUnid) - expect(optsNoUnid.some(o => o.id === 'identify')).toBe(false) - - // Case B: Unidentified items present, but Search for Cain (A1Q4 = index 4) NOT complete - const questFlagsUnrescued = new Uint8Array(48) - questFlagsUnrescued[4] = 0 // not complete - const ctxUnrescued: NpcMenuContext = { - hasUnidentifiedItems: true, - unidentifiedItemCount: 2, - questFlags: questFlagsUnrescued, - } - const optsUnrescued = buildNpcMenuOptions(cain, ctxUnrescued) - const identifyUnrescued = optsUnrescued.find(o => o.id === 'identify') - expect(identifyUnrescued).toBeDefined() - expect(identifyUnrescued?.strIdx).toBe(4021) // 4021 = paid identify with cost - expect(identifyUnrescued?.labelEn).toBe('Identify Items: 200') - - // Case C: Unidentified items present, and Search for Cain complete (A1Q4 = index 4, bit 0 set) - const questFlagsRescued = new Uint8Array(48) - questFlagsRescued[4] = 1 // complete - const ctxRescued: NpcMenuContext = { - hasUnidentifiedItems: true, - unidentifiedItemCount: 2, - questFlags: questFlagsRescued, - } - const optsRescued = buildNpcMenuOptions(cain, ctxRescued) - const identifyRescued = optsRescued.find(o => o.id === 'identify') - expect(identifyRescued).toBeDefined() - expect(identifyRescued?.strIdx).toBe(4020) // 4020 = free identify - expect(identifyRescued?.labelEn).toBe('Identify Items') + const identifyNoUnid = optsNoUnid.find(o => o.id === 'identify') + expect(identifyNoUnid).toBeDefined() + expect(identifyNoUnid?.strIdx).toBe(3399) + expect(identifyNoUnid?.labelEn).toBe('Identify Items') }) - it('2.2 Kashya hire gating: gated by Blood Raven (A1Q2) complete OR player level >= 9', () => { + it('2.2 Kashya static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): always [Talk, Hire, Cancel]', () => { const kashya = getTownNpcDescriptorByName('kashya') expect(kashya).toBeDefined() if (!kashya) return - // Level 1, Blood Raven incomplete -> NO hire const qIncomplete = new Uint8Array(48) qIncomplete[2] = 0 const optsLvl1 = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 1 }) - expect(optsLvl1.some(o => o.id === 'hire')).toBe(false) - - // Level 1, Blood Raven complete -> HIRE available - const qComplete = new Uint8Array(48) - qComplete[2] = 1 - const optsLvl1Complete = buildNpcMenuOptions(kashya, { questFlags: qComplete, playerLevel: 1 }) - expect(optsLvl1Complete.some(o => o.id === 'hire')).toBe(true) - - // Level 9, Blood Raven incomplete -> HIRE available (level override per 1.13c) - const optsLvl9Incomplete = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 9 }) - expect(optsLvl9Incomplete.some(o => o.id === 'hire')).toBe(true) + expect(optsLvl1.map(o => o.id)).toEqual(['talk', 'hire', 'cancel']) }) - it('2.3 Qual-Kehk hire gating: gated strictly by Rescue on Mount Arreat (A5Q2 = index 36)', () => { + it('2.3 Qual-Kehk static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): always [Talk, Hire, Cancel]', () => { const qualKehk = getTownNpcDescriptorByName('qual-kehk') expect(qualKehk).toBeDefined() if (!qualKehk) return - // Incomplete -> NO hire const qIncomplete = new Uint8Array(48) qIncomplete[36] = 0 const optsIncomplete = buildNpcMenuOptions(qualKehk, { questFlags: qIncomplete, playerLevel: 90 }) - expect(optsIncomplete.some(o => o.id === 'hire')).toBe(false) - - // Complete -> HIRE available - const qComplete = new Uint8Array(48) - qComplete[36] = 1 - const optsComplete = buildNpcMenuOptions(qualKehk, { questFlags: qComplete, playerLevel: 90 }) - expect(optsComplete.some(o => o.id === 'hire')).toBe(true) + expect(optsIncomplete.map(o => o.id)).toEqual(['talk', 'hire', 'cancel']) }) - it('2.4 Act Boss Travel gating: Warriv, Meshif, and Tyrael', () => { + it('2.4 Act Boss Travel static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): Warriv, Meshif, and Tyrael', () => { const warriv1 = getTownNpcDescriptorByName('warriv1') const meshif1 = getTownNpcDescriptorByName('meshif1') const tyrael1 = getTownNpcDescriptorByName('tyrael1') - // Warriv east gated by Andariel (A1Q6 = index 6) if (warriv1) { const q0 = new Uint8Array(48) - q0[6] = 0 - expect(buildNpcMenuOptions(warriv1, { questFlags: q0 }).some(o => o.id === 'travel_east')).toBe(false) - const q1 = new Uint8Array(48) - q1[6] = 1 - expect(buildNpcMenuOptions(warriv1, { questFlags: q1 }).some(o => o.id === 'travel_east')).toBe(true) + expect(buildNpcMenuOptions(warriv1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_east', 'cancel']) } - // Meshif east gated by Duriel (A2Q6 = index 14) if (meshif1) { const q0 = new Uint8Array(48) - q0[14] = 0 - expect(buildNpcMenuOptions(meshif1, { questFlags: q0 }).some(o => o.id === 'travel_east')).toBe(false) - const q1 = new Uint8Array(48) - q1[14] = 1 - expect(buildNpcMenuOptions(meshif1, { questFlags: q1 }).some(o => o.id === 'travel_east')).toBe(true) + expect(buildNpcMenuOptions(meshif1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_east', 'cancel']) } - // Tyrael travel harrogath gated by Diablo (A4Q3 = index 26) if (tyrael1) { const q0 = new Uint8Array(48) - q0[26] = 0 - expect(buildNpcMenuOptions(tyrael1, { questFlags: q0 }).some(o => o.id === 'travel_harrogath')).toBe(false) - const q1 = new Uint8Array(48) - q1[26] = 1 - expect(buildNpcMenuOptions(tyrael1, { questFlags: q1 }).some(o => o.id === 'travel_harrogath')).toBe(true) + expect(buildNpcMenuOptions(tyrael1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_harrogath', 'cancel']) } }) @@ -527,8 +468,9 @@ describe('Challenger 2 Empirical Stress Test Harness — NPC Dialogue & World Pa { x: 1000, y: 800 }, ] + const font = new D2FontRenderer() for (const pt of testCoordinates) { - const layout = computeNpcDialogLayout('Deckard Cain', options, pt.x, pt.y) + const layout = computeNpcDialogLayout('Deckard Cain', options, pt.x, pt.y, font) expect(Number.isFinite(layout.left), 'layout.left must be finite').toBe(true) expect(Number.isFinite(layout.top), 'layout.top must be finite').toBe(true) expect(Number.isFinite(layout.width), 'layout.width must be finite').toBe(true) @@ -536,8 +478,9 @@ describe('Challenger 2 Empirical Stress Test Harness — NPC Dialogue & World Pa expect(layout.width).toBeGreaterThan(0) expect(layout.height).toBeGreaterThan(0) - // Must respect viewport clamping bounds (left in [10, 800 - width], top in [10, 600 - height - 48]) + // Must respect DIALOG_Layout viewport clamping bounds (left in [10, 800 - width], top in [10, 552 - height]) expect(layout.left).toBeGreaterThanOrEqual(10) + expect(layout.left).toBeLessThanOrEqual(800 - layout.width) expect(layout.top).toBeGreaterThanOrEqual(10) } }) diff --git a/tests/challenger-m3-tooltip-stress.test.ts b/tests/challenger-m3-tooltip-stress.test.ts index 3077ef8..e0786cf 100644 --- a/tests/challenger-m3-tooltip-stress.test.ts +++ b/tests/challenger-m3-tooltip-stress.test.ts @@ -154,9 +154,9 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => { expect(Number.isFinite(boxW), `boxW must be finite number at (${x}, ${y})`).toBe(true) expect(Number.isFinite(boxH), `boxH must be finite number at (${x}, ${y})`).toBe(true) - // Invariant 2: Dimensions sanity - expect(boxW).toBeGreaterThanOrEqual(190) - expect(boxH).toBeGreaterThanOrEqual(41) + // Invariant 2: Dimensions sanity (lineH = 16, padX = 8, padY = 8) + expect(boxW).toBeGreaterThanOrEqual(16) + expect(boxH).toBeGreaterThanOrEqual(32) // Invariant 3: Clamping within viewport if (boxW <= 784) { @@ -220,8 +220,8 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => { expect(ctx.fillRects.length).toBe(1) const [, , boxW, boxH] = ctx.fillRects[0]! - expect(boxW).toBeGreaterThanOrEqual(190) - expect(boxH).toBeGreaterThanOrEqual(41) + expect(boxW).toBeGreaterThanOrEqual(16) + expect(boxH).toBe(2 * 16 + 16) const texts = font.drawRecords.map(r => r.text) expect(texts).toContain('辨识卷轴') expect(texts).toContain('卷轴') @@ -256,8 +256,8 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => { expect(Number.isFinite(bx)).toBe(true) expect(Number.isFinite(by)).toBe(true) expect(by).toBeGreaterThanOrEqual(8) - // 50 stats + header lines (2) = 52 lines. 52 * 17 + 24 = 908px. - expect(boxH).toBe(52 * 17 + 24) + // 50 stats + header lines (2) = 52 lines. 52 * 16 + 16 = 848px. + expect(boxH).toBe(52 * 16 + 16) expect(font.drawRecords.length).toBe(52) }) @@ -383,8 +383,8 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => { const [bx, by, boxW, boxH] = ctx.fillRects[0]! expect(Number.isFinite(bx)).toBe(true) expect(Number.isFinite(by)).toBe(true) - expect(boxW).toBeGreaterThanOrEqual(190) - expect(boxH).toBeGreaterThanOrEqual(41) + expect(boxW).toBeGreaterThanOrEqual(16) + expect(boxH).toBeGreaterThanOrEqual(32) }) it('routes titleColor correctly for all 8 item qualities', () => { @@ -422,7 +422,7 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => { const titleRecord = font.drawRecords.find(r => r.text === `${quality} 测试道具`) expect(titleRecord, `Title for ${quality} must be rendered`).toBeDefined() expect(titleRecord?.color, `Title color for ${quality} must be ${expectedColor}`).toBe(expectedColor) - expect(titleRecord?.font).toBe('fontexocet10') + expect(titleRecord?.font).toBe('font16') } }) @@ -566,12 +566,12 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => { throw new Error(`Fuzz iteration ${i} produced non-finite coordinates: bx=${bx}, by=${by}, boxW=${boxW}, boxH=${boxH}`) } - // Invariant: Dimensions >= minimums - if (boxW < 190) { - throw new Error(`Fuzz iteration ${i} produced boxW < 190: ${boxW}`) + // Invariant: Dimensions >= minimums (padX*2 = 16, lineH + padY*2 = 32) + if (boxW < 16) { + throw new Error(`Fuzz iteration ${i} produced boxW < 16: ${boxW}`) } - if (boxH < 41) { - throw new Error(`Fuzz iteration ${i} produced boxH < 41: ${boxH}`) + if (boxH < 32) { + throw new Error(`Fuzz iteration ${i} produced boxH < 32: ${boxH}`) } // Invariant: Clamping logic diff --git a/tests/client/world-panels-batch6.test.ts b/tests/client/world-panels-batch6.test.ts new file mode 100644 index 0000000..f340e50 --- /dev/null +++ b/tests/client/world-panels-batch6.test.ts @@ -0,0 +1,590 @@ +/** + * tests/client/world-panels-batch6.test.ts + * + * Diablo II v1.13c Milestone M6 Phase 1 Parity Verification Suite + * Covers Issues #664 through #678 (15 issues) in `src/client/ui/world-panels.ts` and `src/common/units/npc-table.ts`. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + ACT_TAB_LAYOUT, + ACT_WAYPOINTS, + BUTTON_HOVER_BOX_H, + BUTTON_HOVER_BOX_MIN_W, + BUTTON_HOVER_BOX_PAD_X, + CUBE_GRID_ORIGIN, + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_GREEN, + GRID_ITEM_BG_RED, + NPC_MENU_HEAD_OFFSET_Y, + NPC_MENU_MIN_ANCHOR_Y, + STONE_BOX_BG_FILL, + VENDOR_BUTTON_SPECS, + VENDOR_TABS, + WAYPOINT_ROW_OFFSETS_Y, + WAYPOINT_TEXT_OFFSETS_Y, + WorldPanelsHud, + buildNpcMenuOptions, + computeNpcDialogLayout, + computeNpcMenuAnchor, + computeNpcMenuAnchorY, + getActQuests, + getActWaypoints, + getWaypointRowOffsetY, + getWaypointTextOffsetY, + resolveGridItemBackgroundFill, + resolvePartyActionCode, + resolveWaypointName, +} from '../../src/client/ui/world-panels.ts' +import { D2FontRenderer } from '../../src/client/ui/font.ts' +import { getTownNpcDescriptorByHcIdx, getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts' +import type { UiInventoryItem } from '../../src/client/ui/inventory.ts' + +function createMockCtx() { + const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = [] + const strokeRectCalls: Array<{ x: number; y: number; w: number; h: number; strokeStyle: string }> = [] + const drawImageCalls: any[][] = [] + const ctx = { + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + globalAlpha: 1, + fillRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) { + fillRectCalls.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) }) + }), + strokeRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) { + strokeRectCalls.push({ x, y, w, h, strokeStyle: String(ctx.strokeStyle) }) + }), + drawImage: vi.fn((...args: any[]) => { + drawImageCalls.push(args) + }), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + fillText: vi.fn(), + measureText: vi.fn((text: string) => ({ width: text.length * 8 })), + } as unknown as CanvasRenderingContext2D + return { ctx, fillRectCalls, strokeRectCalls, drawImageCalls } +} + +function makeTestItem(overrides: Partial = {}): UiInventoryItem { + return { + id: 'item-1', + code: 'cap', + invFile: 'invcap', + name: 'Cap', + nameZh: '帽子', + baseNameZh: '帽子', + quality: 'normal', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + stats: [], + ...overrides, + } +} + +describe('Milestone M6 Phase 1: World Panels, NPC Menus, Waypoints, Tooltips & Fail-Fast (#664–#678)', () => { + // =========================================================================== + // Issue #664: NPC Menu Anchor above NPC Head & 0x6fb53580 Layout Formula + // =========================================================================== + describe('Issue #664: NPC menu anchor above NPC head and 0x6fb53580 layout', () => { + it('computes anchorY = Math.max(20, npcScreenY - 150) per 0x6faf68de..0x6faf68f8', () => { + expect(NPC_MENU_HEAD_OFFSET_Y).toBe(150) + expect(NPC_MENU_MIN_ANCHOR_Y).toBe(20) + expect(computeNpcMenuAnchorY(300)).toBe(150) + expect(computeNpcMenuAnchorY(160)).toBe(20) + expect(computeNpcMenuAnchorY(50)).toBe(20) + expect(computeNpcMenuAnchor(420, 320)).toEqual({ x: 420, y: 170 }) + }) + + it('applies 0x6fb53580 layout formula: width = maxLineW + 20, height = totalStepY + 15, left = anchorX - w/2, top = anchorY - 21', () => { + const font = new D2FontRenderer() + const akara = getTownNpcDescriptorByName('akara')! + const options = buildNpcMenuOptions(akara) + const layout = computeNpcDialogLayout('Akara', options, 400, 200, font, 'ENG', 800, 600) + + const headerW = font.measureText('Akara', 'font16') + const optWidths = options.map(o => font.measureText(o.labelEn, 'font16')) + const expectedMaxW = Math.max(headerW, ...optWidths) + const expectedTotalStepY = 21 + options.length * 15 + expect(layout.width).toBe(expectedMaxW + 20) + expect(layout.height).toBe(expectedTotalStepY + 15) + expect(layout.left).toBe(400 - Math.trunc(layout.width / 2)) + expect(layout.top).toBe(200 - 21) + }) + + it('anchors openNpcMenuAtUnitScreen above NPC screen Y (npcScreenY - 150, clamped >= 20)', () => { + const hud = new WorldPanelsHud() + const charsi = getTownNpcDescriptorByName('charsi')! + hud.openNpcMenuAtUnitScreen(charsi, 420, 320, 101) + expect(hud.npcMenu).not.toBeNull() + expect(hud.npcMenu!.x).toBe(420) + expect(hud.npcMenu!.y).toBe(170) // 320 - 150 + }) + }) + + // =========================================================================== + // Issue #665: Preserve hoveredOptionIdx across syncFromWorld / openNpcMenu + // =========================================================================== + describe('Issue #665: Preserve NPC menu hoveredOptionIdx on repeated sync ticks', () => { + it('preserves hoveredOptionIdx when openNpcMenu is called again for the same npcId', () => { + const hud = new WorldPanelsHud() + const gheed = getTownNpcDescriptorByName('gheed')! + hud.openNpcMenu(gheed, 400, 300, 55) + expect(hud.npcMenu!.hoveredOptionIdx).toBe(0) + + // Player hovers option index 2 ('gamble') + hud.npcMenu!.hoveredOptionIdx = 2 + + // Subsequent sync tick refreshes openNpcMenu for the same NPC (npcId = 55) + hud.openNpcMenu(gheed, 404, 302, 55) + expect(hud.npcMenu!.hoveredOptionIdx).toBe(2) + expect(hud.npcMenu!.x).toBe(404) + expect(hud.npcMenu!.y).toBe(302) + }) + }) + + // =========================================================================== + // Issue #666: Canonical Static NPC Menu Option Sets & Ordering (0x6fba3ba0) + // =========================================================================== + describe('Issue #666: Static NPC menu option sets and ordering (0x6fba3ba0)', () => { + it('halbu (hcIdx 257) has ONLY trade_repair and cancel — NO talk option', () => { + const halbu = getTownNpcDescriptorByHcIdx(257)! + expect(halbu.id).toBe('halbu') + expect(halbu.canTalk).toBe(false) + const opts = buildNpcMenuOptions(halbu) + expect(opts.map(o => o.id)).toEqual(['trade_repair', 'cancel']) + }) + + it('jamella (hcIdx 405) has ONLY trade, gamble, and cancel — NO talk option', () => { + const jamella = getTownNpcDescriptorByHcIdx(405)! + expect(jamella.id).toBe('jamella') + expect(jamella.canTalk).toBe(false) + const opts = buildNpcMenuOptions(jamella) + expect(opts.map(o => o.id)).toEqual(['trade', 'gamble', 'cancel']) + }) + + it('asheara (hcIdx 252) orders hire BEFORE trade: [talk, hire, trade, cancel]', () => { + const asheara = getTownNpcDescriptorByHcIdx(252)! + expect(asheara.id).toBe('asheara') + expect(asheara.hireBeforeTrade).toBe(true) + const opts = buildNpcMenuOptions(asheara) + expect(opts.map(o => o.id)).toEqual(['talk', 'hire', 'trade', 'cancel']) + }) + }) + + // =========================================================================== + // Issue #667: Waypoint Names via levels.txt -> .tbl & Binary Row/Text Y Table + // =========================================================================== + describe('Issue #667: Waypoint names from .tbl and binary row/text geometry (@0x6fb8e6d8)', () => { + it('resolves Traditional Chinese and English waypoint names from canonical .tbl keys', () => { + expect(ACT_WAYPOINTS[1]![0]!.nameZh).toBe('蘿格營地') + expect(ACT_WAYPOINTS[1]![0]!.nameEn).toBe('Rogue Encampment') + expect(ACT_WAYPOINTS[2]![0]!.nameZh).toBe('魯.高因') + expect(ACT_WAYPOINTS[3]![0]!.nameZh).toBe('庫拉斯特海港') + expect(ACT_WAYPOINTS[4]![0]!.nameZh).toBe('群魔堡壘') + expect(ACT_WAYPOINTS[5]![0]!.nameZh).toBe('哈洛加斯') + expect(resolveWaypointName(1, 'CHI')).toBe('蘿格營地') + expect(resolveWaypointName(1, 'ENG')).toBe('Rogue Encampment') + }) + + it('exports canonical WAYPOINT_ROW_OFFSETS_Y and WAYPOINT_TEXT_OFFSETS_Y from @0x6fb8e6d8 and draws no custom row boxes', () => { + expect(WAYPOINT_ROW_OFFSETS_Y).toEqual([60, 96, 132, 168, 205, 241, 277, 313, 349]) + expect(WAYPOINT_TEXT_OFFSETS_Y).toEqual([84, 119, 154, 189, 224, 259, 294, 329, 364]) + expect(getWaypointRowOffsetY(0)).toBe(60) + expect(getWaypointTextOffsetY(0)).toBe(84) + + const hud = new WorldPanelsHud() + hud.selectedActTab = 1 + hud.setWaypointUnlocked(0, true) + hud.setCurrentWaypoint(0, 1) + + const { ctx, fillRectCalls, strokeRectCalls } = createMockCtx() + const textCalls: Array<{ text: string; x: number; y: number }> = [] + const font = { + measureText: vi.fn(() => 80), + drawText: vi.fn((_c: any, text: string, x: number, y: number) => { + textCalls.push({ text, x, y }) + }), + } as unknown as D2FontRenderer + + const dummyImg = { complete: true, naturalWidth: 640, naturalHeight: 480 } as unknown as HTMLImageElement + hud.drawLeftDockPanel( + ctx, + 'waypoint', + { + questBgImg: dummyImg, + waypointBgImg: dummyImg, + borderLeftImg: dummyImg, + stashBgImg: dummyImg, + cubeBgImg: dummyImg, + vendorBgImg: dummyImg, + buySellBtnImg: dummyImg, + waypointTabsImg: dummyImg, + waypointIconsImg: dummyImg, + }, + font, + ) + + // No custom row background fillRect or strokeRect + expect(fillRectCalls).toHaveLength(0) + expect(strokeRectCalls).toHaveLength(0) + + // First row text drawn at ox + 80 = 160, oy + 84 = 144 + const rogueRow = textCalls.find(c => c.text === '蘿格營地') + expect(rogueRow).toBeDefined() + expect(rogueRow!.x).toBe(80 + 80) + expect(rogueRow!.y).toBe(60 + 84) + }) + }) + + // =========================================================================== + // Issue #668: Item Hover Tooltip Uniform font16, Speed Line Order, No Border + // =========================================================================== + describe('Issue #668: Item hover tooltip appearance, uniform font16, and speed line ordering', () => { + it('renders all lines with font16, places weapon speed after Required Level, uses STONE_BOX_BG_FILL, and draws no border stroke', () => { + const hud = new WorldPanelsHud() + hud.lang = 'ENG' + const { ctx, fillRectCalls, strokeRectCalls } = createMockCtx() + const drawnLines: Array<{ text: string; font: string }> = [] + const font = { + measureText: vi.fn((text: string) => text.length * 8), + drawText: vi.fn((_c: any, text: string, _x: number, _y: number, opts: any) => { + drawnLines.push({ text, font: opts.font }) + }), + } as unknown as D2FontRenderer + + const weapon = makeTestItem({ + id: 'w1', + code: '9cr', + name: 'Phase Blade', + nameZh: '幻化之刃', + quality: 'normal', + invWidth: 2, + invHeight: 3, + damageEn: 'One-Hand Damage: 31 to 35', + reqDex: 136, + reqStr: 25, + reqLevel: 54, + speedTextEn: 'Sword Class - Very Fast Attack Speed', + allowedSlots: ['weapon1', 'weapon2'], + stats: [], + }) + + hud.drawItemTooltip(ctx, { item: weapon, x: 400, y: 300 }, font, false, 800, 600) + + // Every line uses font16 + expect(drawnLines.length).toBeGreaterThan(0) + for (const l of drawnLines) { + expect(l.font).toBe('font16') + } + + // Speed line must appear AFTER Required Dexterity, Required Strength, and Required Level + const reqDexIdx = drawnLines.findIndex(l => l.text.startsWith('Required Dexterity:')) + const reqStrIdx = drawnLines.findIndex(l => l.text.startsWith('Required Strength:')) + const reqLvlIdx = drawnLines.findIndex(l => l.text.startsWith('Required Level:')) + const speedIdx = drawnLines.findIndex(l => l.text.includes('Attack Speed')) + expect(reqDexIdx).toBeGreaterThan(-1) + expect(reqStrIdx).toBeGreaterThan(reqDexIdx) + expect(reqLvlIdx).toBeGreaterThan(reqStrIdx) + expect(speedIdx).toBeGreaterThan(reqLvlIdx) + + // Background uses STONE_BOX_BG_FILL and NO border stroke + expect(fillRectCalls).toHaveLength(1) + expect(fillRectCalls[0]!.fillStyle).toBe(STONE_BOX_BG_FILL) + expect(strokeRectCalls).toHaveLength(0) + + // Box width is maxW + 16 without arbitrary 190px floor on short tooltips + const shortItem = makeTestItem({ + id: 'k1', + code: 'key', + name: 'Key', + nameZh: '鑰匙', + quality: 'normal', + invWidth: 1, + invHeight: 1, + allowedSlots: [], + stats: [], + }) + fillRectCalls.length = 0 + hud.drawItemTooltip(ctx, { item: shortItem, x: 400, y: 300 }, font, false, 800, 600) + expect(fillRectCalls[0]!.w).toBe(24 + 16) // 'Key' width 24 + padX*2 (16) = 40 (< 190) + }) + }) + + // =========================================================================== + // Issue #669: Horadric Cube Grid X = 198 (80 + 118) & Item Background Fill + // =========================================================================== + describe('Issue #669: Horadric Cube grid origin (198, 199) and item background fill', () => { + it('exports CUBE_GRID_ORIGIN with x = 198 (80 + 118), y = 199 (60 + 139), cols = 3, rows = 4, cellPx = 29', () => { + expect(CUBE_GRID_ORIGIN).toEqual({ + x: 198, + y: 199, + cols: 3, + rows: 4, + cellPx: 29, + }) + }) + + it('uses uniform blue/red/green item cell background fill regardless of unique/set quality', () => { + const uniqueHelm = makeTestItem({ quality: 'unique', reqLevel: 10 }) + const usableCtx = { level: 20, str: 50, dex: 50 } + const unusableCtx = { level: 1, str: 10, dex: 10 } + expect(resolveGridItemBackgroundFill(uniqueHelm, usableCtx)).toBe(GRID_ITEM_BG_BLUE) + expect(resolveGridItemBackgroundFill(uniqueHelm, unusableCtx)).toBe(GRID_ITEM_BG_RED) + expect(resolveGridItemBackgroundFill(uniqueHelm, usableCtx, { isHovered: true, hasCursorItem: true })).toBe( + GRID_ITEM_BG_GREEN, + ) + }) + }) + + // =========================================================================== + // Issue #670: Fail-Fast on Missing UI Assets, Fonts, and Item Sprites + // =========================================================================== + describe('Issue #670: Fail-fast when required UI assets, fonts, or item sprites are missing', () => { + it('throws when computeNpcDialogLayout is called without a valid D2FontRenderer', () => { + const opts = buildNpcMenuOptions(getTownNpcDescriptorByName('akara')!) + expect(() => computeNpcDialogLayout('Akara', opts, 400, 200, null)).toThrow(/D2FontRenderer/) + }) + + it('throws when rendering quest or waypoint panel with unloaded/null required images', () => { + const hud = new WorldPanelsHud() + const font = new D2FontRenderer() + const { ctx } = createMockCtx() + const dummyImg = { complete: true, naturalWidth: 320, naturalHeight: 432 } as unknown as HTMLImageElement + + expect(() => + hud.drawLeftDockPanel( + ctx, + 'quest', + { + questBgImg: dummyImg, + waypointBgImg: dummyImg, + borderLeftImg: dummyImg, + stashBgImg: dummyImg, + cubeBgImg: dummyImg, + vendorBgImg: dummyImg, + buySellBtnImg: dummyImg, + questsAtlasImg: null, + }, + font, + ), + ).toThrow(/questsAtlasImg/) + }) + + it('throws when rendering stash item without a valid itemsAtlasImg', () => { + const hud = new WorldPanelsHud() + const font = new D2FontRenderer() + const { ctx } = createMockCtx() + const dummyImg = { complete: true, naturalWidth: 320, naturalHeight: 432 } as unknown as HTMLImageElement + + hud.stashItems = [{ item: makeTestItem({ code: 'cap', quality: 'unique' }), col: 0, row: 0 }] + + expect(() => + hud.drawLeftDockPanel( + ctx, + 'stash', + { + questBgImg: dummyImg, + waypointBgImg: dummyImg, + borderLeftImg: dummyImg, + stashBgImg: dummyImg, + cubeBgImg: dummyImg, + vendorBgImg: dummyImg, + buySellBtnImg: dummyImg, + itemsAtlasImg: null, + }, + font, + ), + ).toThrow(/itemsAtlasImg/) + }) + }) + + // =========================================================================== + // Issue #671: Fail-Fast on Missing Required Runtime Data (No ?? Fallbacks) + // =========================================================================== + describe('Issue #671: Fail-fast on missing required runtime data', () => { + it('throws on invalid Act index in getActWaypoints and getActQuests instead of falling back to Act 1', () => { + expect(() => getActWaypoints(0)).toThrow(/Invalid waypoint act index/) + expect(() => getActWaypoints(6)).toThrow(/Invalid waypoint act index/) + expect(() => getActQuests(0)).toThrow(/Invalid quest act index/) + expect(() => getActQuests(6)).toThrow(/Invalid quest act index/) + expect(getActWaypoints(1)).toBe(ACT_WAYPOINTS[1]) + }) + + it('throws when getWaypointRowOffsetY or getWaypointTextOffsetY receives an out-of-bounds row index', () => { + expect(() => getWaypointRowOffsetY(-1)).toThrow(/Invalid waypoint row index/) + expect(() => getWaypointRowOffsetY(9)).toThrow(/Invalid waypoint row index/) + expect(() => getWaypointTextOffsetY(-1)).toThrow(/Invalid waypoint text row index/) + expect(() => getWaypointTextOffsetY(9)).toThrow(/Invalid waypoint text row index/) + }) + }) + + // =========================================================================== + // Issue #672: Remove Duplicate Offline Local Rules + // =========================================================================== + describe('Issue #672: Offline duplicate rules removed from WorldPanelsHud', () => { + it('does not expose canTransmuteCube, resolveGambleOutcome, or getRepairCost on WorldPanelsHud', () => { + const hud = new WorldPanelsHud() as unknown as Record + expect(hud['canTransmuteCube']).toBeUndefined() + expect(hud['resolveGambleOutcome']).toBeUndefined() + expect(hud['getRepairCost']).toBeUndefined() + }) + }) + + // =========================================================================== + // Issue #673: Non-Canonical "Entering: " Banner Removed + // =========================================================================== + describe('Issue #673: Non-canonical Entering area banner removed', () => { + it('keeps areaBanner null and draws nothing on showAreaBanner / drawAreaBanner', () => { + const hud = new WorldPanelsHud() + const font = new D2FontRenderer() + const { ctx, fillRectCalls } = createMockCtx() + const drawTextSpy = vi.spyOn(font, 'drawText') + + hud.showAreaBanner('鮮血荒地', 'Blood Moor', 1000) + expect(hud.areaBanner).toBeNull() + + hud.drawAreaBanner(ctx, 1500, font, 800) + expect(drawTextSpy).not.toHaveBeenCalled() + expect(fillRectCalls).toHaveLength(0) + }) + }) + + // =========================================================================== + // Issue #674: Unified Act Tab Draw & Hit-Test Geometry + // =========================================================================== + describe('Issue #674: Unified Act tab draw and click hit-test coordinates', () => { + it('uses identical ACT_TAB_LAYOUT bounds for drawing and hit-testing across all 5 tabs', () => { + const hud = new WorldPanelsHud() + const ox = 80 + const oy = 60 + for (const panel of ['quest', 'waypoint'] as const) { + for (let i = 0; i < ACT_TAB_LAYOUT.count; i++) { + const tabLeft = ox + ACT_TAB_LAYOUT.startXOffset + i * ACT_TAB_LAYOUT.strideX + const tabRight = + i === ACT_TAB_LAYOUT.count - 1 + ? tabLeft + ACT_TAB_LAYOUT.frameWidth - 1 + : tabLeft + ACT_TAB_LAYOUT.strideX - 1 + const tabTop = oy + ACT_TAB_LAYOUT.topYOffset + const tabBottom = oy + ACT_TAB_LAYOUT.topYOffset + ACT_TAB_LAYOUT.height + + hud.selectedActTab = 1 + hud.handleLeftDockClick(panel, tabLeft, tabTop, { + onClose: () => {}, + onWaypointTeleport: () => {}, + }) + expect(hud.selectedActTab).toBe(i + 1) + + hud.selectedActTab = 1 + hud.handleLeftDockClick(panel, tabRight, tabBottom, { + onClose: () => {}, + onWaypointTeleport: () => {}, + }) + expect(hud.selectedActTab).toBe(i + 1) + } + } + }) + }) + + // =========================================================================== + // Issue #675: Binary-Grounded Constants Exported & Verified + // =========================================================================== + describe('Issue #675: Binary-grounded constants exported with 1.13c provenance', () => { + it('exports STONE_BOX_BG_FILL, BUTTON_HOVER_BOX_*, and ACT_TAB_LAYOUT', () => { + expect(STONE_BOX_BG_FILL).toBe('rgba(0, 0, 0, 0.85)') + expect(BUTTON_HOVER_BOX_PAD_X).toBe(6) + expect(BUTTON_HOVER_BOX_MIN_W).toBe(24) + expect(BUTTON_HOVER_BOX_H).toBe(22) + expect(ACT_TAB_LAYOUT).toEqual({ + startXOffset: 4, + strideX: 62, + width: 62, + frameWidth: 63, + height: 31, + topYOffset: 8, + bottomYOffset: 39, + count: 5, + }) + }) + }) + + // =========================================================================== + // Issue #676: Vendor Buttons & Tabs Resolved Dynamically from .tbl via strIdx + // =========================================================================== + describe('Issue #676: Vendor button and tab labels resolved from .tbl via strIdx', () => { + it('resolves VENDOR_BUTTON_SPECS and VENDOR_TABS labels dynamically via requireTbl', () => { + expect(VENDOR_BUTTON_SPECS.buy.labelEn).toBe('Buy') + expect(VENDOR_BUTTON_SPECS.buy.labelZh).toBe('買') + expect(VENDOR_BUTTON_SPECS.sell.labelEn).toBe('Sell') + expect(VENDOR_BUTTON_SPECS.sell.labelZh).toBe('賣') + expect(VENDOR_BUTTON_SPECS.repair.labelEn).toBe('Repair') + expect(VENDOR_BUTTON_SPECS.repair.labelZh).toBe('修復') + expect(VENDOR_BUTTON_SPECS.close.labelEn).toBe('Close') + expect(VENDOR_BUTTON_SPECS.close.labelZh).toBe('關閉') + + expect(VENDOR_TABS[0]!.labelEn).toBe('Armor') + expect(VENDOR_TABS[1]!.labelEn).toBe('Weapons') + expect(VENDOR_TABS[3]!.labelEn).toBe('Misc') + }) + }) + + // =========================================================================== + // Issue #677: Single 0x31 QuestMessage Dispatch on Clicking "Talk" + // =========================================================================== + describe('Issue #677: Single 0x31 QuestMessage dispatch on clicking Talk', () => { + it('fires exactly one packet when both onNpcMenuAction and onQuestMessage are bound', () => { + const hud = new WorldPanelsHud() + const akara = getTownNpcDescriptorByName('akara')! + const dispatched: Array<{ source: string; npcId: number; msgId?: number }> = [] + + hud.onNpcMenuAction = (optionId, npcUnitId) => { + if (optionId === 'talk') { + dispatched.push({ source: 'onNpcMenuAction', npcId: npcUnitId }) + } + } + hud.onQuestMessage = (npcUnitId, messageId) => { + dispatched.push({ source: 'onQuestMessage', npcId: npcUnitId, msgId: messageId }) + } + + hud.openNpcMenu(akara, 400, 300, 101) + expect(hud.clickNpcMenuOption('talk')).toBe(true) + expect(dispatched).toEqual([{ source: 'onNpcMenuAction', npcId: 101 }]) + }) + }) + + // =========================================================================== + // Issue #678: Strict Canonical D2GS 0x5E Party Action Codes + // =========================================================================== + describe('Issue #678: Strict canonical D2GS 0x5E party action codes in resolvePartyActionCode', () => { + it('maps canonical relationship states to 0x06, 0x07, 0x08, 0x09 and throws on unknown/missing values', () => { + expect(resolvePartyActionCode({ relationship: 'none' })).toBe(0x06) + expect(resolvePartyActionCode({ relationship: 0 })).toBe(0x06) + expect(resolvePartyActionCode({ partyState: 0x06 })).toBe(0x06) + + expect(resolvePartyActionCode({ relationship: 'invited' })).toBe(0x07) + expect(resolvePartyActionCode({ relationship: 'invited_by_me' })).toBe(0x07) + expect(resolvePartyActionCode({ relationship: 1 })).toBe(0x07) + expect(resolvePartyActionCode({ partyState: 0x07 })).toBe(0x07) + + expect(resolvePartyActionCode({ relationship: 'invited_me' })).toBe(0x08) + expect(resolvePartyActionCode({ relationship: 'invited_by' })).toBe(0x08) + expect(resolvePartyActionCode({ relationship: 2 })).toBe(0x08) + expect(resolvePartyActionCode({ partyState: 0x08 })).toBe(0x08) + + expect(resolvePartyActionCode({ relationship: 'in_party' })).toBe(0x09) + expect(resolvePartyActionCode({ relationship: 'party' })).toBe(0x09) + expect(resolvePartyActionCode({ relationship: 3 })).toBe(0x09) + expect(resolvePartyActionCode({ partyState: 0x09 })).toBe(0x09) + + expect(() => resolvePartyActionCode(null)).toThrow(/valid party member/) + expect(() => resolvePartyActionCode({})).toThrow(/unrecognized party relationship/) + expect(() => resolvePartyActionCode({ relationship: ' bogus ' })).toThrow(/unrecognized party relationship/) + expect(() => resolvePartyActionCode({ relationship: 99 })).toThrow(/unrecognized party relationship/) + }) + }) +}) diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts index 2d30cf3..34200ab 100644 --- a/tests/issue-503-bilingual-parity.test.ts +++ b/tests/issue-503-bilingual-parity.test.ts @@ -270,20 +270,20 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () const worldPanels = new WorldPanelsHud() worldPanels.lang = 'ENG' const assets = { - borderLeftImg: null, - questBgImg: null, - waypointBgImg: null, - stashBgImg: null, - cubeBgImg: null, - vendorBgImg: null, - vendorTabsImg: null, - buySellBtnImg: null, - miniConvertImg: null, - questsAtlasImg: null, - itemsAtlasImg: null, - waypointTabsImg: null, - waypointIconsImg: null, - questTabsImg: null, + borderLeftImg: mockImg, + questBgImg: mockImg, + waypointBgImg: mockImg, + stashBgImg: mockImg, + cubeBgImg: mockImg, + vendorBgImg: mockImg, + vendorTabsImg: mockImg, + buySellBtnImg: mockImg, + miniConvertImg: mockImg, + questsAtlasImg: mockImg, + itemsAtlasImg: mockImg, + waypointTabsImg: mockImg, + waypointIconsImg: mockImg, + questTabsImg: mockImg, inventory: invPanel, } worldPanels.drawLeftDockPanel(ctx, 'quest', assets, font) @@ -426,21 +426,22 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () const invPanel = new InventoryPanel() const worldPanels = new WorldPanelsHud() worldPanels.lang = 'CHI' + const mockImg = { complete: true, naturalWidth: 640, naturalHeight: 480 } as unknown as HTMLImageElement const assets = { - borderLeftImg: null, - questBgImg: null, - waypointBgImg: null, - stashBgImg: null, - cubeBgImg: null, - vendorBgImg: null, - vendorTabsImg: null, - buySellBtnImg: null, - miniConvertImg: null, - questsAtlasImg: null, - itemsAtlasImg: null, - waypointTabsImg: null, - waypointIconsImg: null, - questTabsImg: null, + borderLeftImg: mockImg, + questBgImg: mockImg, + waypointBgImg: mockImg, + stashBgImg: mockImg, + cubeBgImg: mockImg, + vendorBgImg: mockImg, + vendorTabsImg: mockImg, + buySellBtnImg: mockImg, + miniConvertImg: mockImg, + questsAtlasImg: mockImg, + itemsAtlasImg: mockImg, + waypointTabsImg: mockImg, + waypointIconsImg: mockImg, + questTabsImg: mockImg, inventory: invPanel, } worldPanels.drawLeftDockPanel(ctx, 'waypoint', assets, font) @@ -450,7 +451,7 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () expect(textCalls).toContain('力量') expect(textCalls).toContain('剩余技能点') expect(textCalls.some(t => t === '角色属性 (A/C)' || t === '人物(A)')).toBe(true) - expect(textCalls).toContain('罗格营地') + expect(textCalls).toContain('蘿格營地') // tbl key 'Rogue Encampment' (CHI) expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI) expect(textCalls).not.toContain('角色属性 (Character)') diff --git a/tests/item-tooltip-color-dye.test.ts b/tests/item-tooltip-color-dye.test.ts index b37191b..215ff29 100644 --- a/tests/item-tooltip-color-dye.test.ts +++ b/tests/item-tooltip-color-dye.test.ts @@ -317,19 +317,19 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () const calls = mockFont.drawCalls expect(calls.length, 'Tooltip must render multiple text lines').toBeGreaterThanOrEqual(3) - // Line 0: Runeword Title in fontexocet10 with gold color + // Line 0: Runeword Title in font16 with gold color expect(calls[0]!.text).toBe('战争召唤') - expect(calls[0]!.options?.font).toBe('fontexocet10') + expect(calls[0]!.options?.font).toBe('font16') expect(calls[0]!.options?.color).toBe('gold') - // Line 1: Socketed Runes line in font8 with gold color + // Line 1: Socketed Runes line in font16 with gold color expect(calls[1]!.text).toBe("'AmnRalMalIstOhm'") - expect(calls[1]!.options?.font).toBe('font8') + expect(calls[1]!.options?.font).toBe('font16') expect(calls[1]!.options?.color).toBe('gold') - // Line 2: Base Item Name in font8 with white/gray color + // Line 2: Base Item Name in font16 with white/gray color expect(calls[2]!.text).toBe('水晶剑') - expect(calls[2]!.options?.font).toBe('font8') + expect(calls[2]!.options?.font).toBe('font16') expect(['white', 'gray']).toContain(calls[2]!.options?.color) }) @@ -365,31 +365,31 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () // Find damage line const dmgCall = calls.find(c => c.text.includes('17') && c.text.includes('52')) expect(dmgCall, 'Damage line 17 - 52 must be drawn').toBeDefined() - expect(dmgCall!.options?.font).toBe('font8') + expect(dmgCall!.options?.font).toBe('font16') expect(['white', 'gray']).toContain(dmgCall!.options?.color) // Find durability line const duraCall = calls.find(c => c.text.includes('耐久度') && c.text.includes('20')) expect(duraCall, 'Durability line 20 之 20 must be drawn').toBeDefined() - expect(duraCall!.options?.font).toBe('font8') + expect(duraCall!.options?.font).toBe('font16') expect(['white', 'gray']).toContain(duraCall!.options?.color) // Find speed class line const speedCall = calls.find(c => c.text.includes('极快的攻击速度')) expect(speedCall, 'Weapon speed class line must be drawn').toBeDefined() - expect(speedCall!.options?.font).toBe('font8') + expect(speedCall!.options?.font).toBe('font16') expect(['white', 'gray']).toContain(speedCall!.options?.color) // Find reqStr line const reqStrCall = calls.find(c => c.text.includes('需要力量') && c.text.includes('43')) expect(reqStrCall, 'Req strength 43 must be drawn').toBeDefined() - expect(reqStrCall!.options?.font).toBe('font8') + expect(reqStrCall!.options?.font).toBe('font16') expect(['white', 'gray']).toContain(reqStrCall!.options?.color) // Find reqLevel line const reqLvlCall = calls.find(c => c.text.includes('需要等级') && c.text.includes('57')) expect(reqLvlCall, 'Req level 57 must be drawn').toBeDefined() - expect(reqLvlCall!.options?.font).toBe('font8') + expect(reqLvlCall!.options?.font).toBe('font16') expect(['white', 'gray']).toContain(reqLvlCall!.options?.color) }) @@ -433,7 +433,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () // Check sockets line is blue const socketsCall = calls.find(c => c.text.includes('凹槽') && c.text.includes('5')) expect(socketsCall, 'Sockets line 凹槽 (5) must be drawn in blue').toBeDefined() - expect(socketsCall!.options?.font).toBe('font8') + expect(socketsCall!.options?.font).toBe('font16') expect(socketsCall!.options?.color).toBe('blue') }) }) @@ -712,7 +712,7 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () const titleCall = mockFont.drawCalls[0] expect(titleCall, `Quality ${itemDef.quality} must draw a title line`).toBeDefined() expect(titleCall!.text).toBe(itemDef.nameZh) - expect(titleCall!.options?.font).toBe('fontexocet10') + expect(titleCall!.options?.font).toBe('font16') expect(titleCall!.options?.color, `Quality ${itemDef.quality} title color must be ${itemDef.expectedTitleColor}`).toBe(itemDef.expectedTitleColor) } }) @@ -800,16 +800,16 @@ describe('Diablo II v1.13c Item Tooltip & Color Dye Parity (Requirement R5)', () const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta')! worldPanels.drawItemTooltip(mockCtx, { item: ctaPlacement.item, x: 400, y: 350 }, mockFont) - // Verify fillRect and strokeRect + // Verify fillRect and no strokeRect per D2Win_10124 (Issue #668) expect(mockCtx.fillRectCalls.length).toBe(1) - expect(mockCtx.strokeRectCalls.length).toBe(1) + expect(mockCtx.strokeRectCalls.length).toBe(0) const [fx, fy, fw, fh] = mockCtx.fillRectCalls[0]! expect(Number.isFinite(fx)).toBe(true) expect(Number.isFinite(fy)).toBe(true) expect(Number.isFinite(fw)).toBe(true) expect(Number.isFinite(fh)).toBe(true) - expect(fw).toBeGreaterThanOrEqual(190) + expect(fw).toBeGreaterThanOrEqual(100) expect(fh).toBeGreaterThanOrEqual(50) // Verify all text draw calls have finite numeric coordinates diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 38a7cbf..e4b2d9b 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -262,7 +262,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(ACT_WAYPOINTS[1]!.length).toBe(9) expect(ACT_WAYPOINTS[5]!.length).toBe(9) worldPanels.showAreaBanner('罗格营地', 'Rogue Encampment', 1000) - expect(worldPanels.areaBanner?.titleZh).toBe('罗格营地') + expect(worldPanels.areaBanner).toBeNull() }) it('enforces 1:1 uniform aspect ratio HUD scaling without horizontal distortion (Issue #136)', () => { @@ -576,16 +576,12 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(sceneNameZh('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon') expect(sceneNameEn('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon') - // 7. Area banner display logic in WorldPanelsHud + // 7. Area banner display logic in WorldPanelsHud (no-op in 1.13c per Issue #673) const worldPanels = new WorldPanelsHud() const now = 1000 const bgNames = getLevelNames(17) worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now) - expect(worldPanels.areaBanner).toEqual({ - titleZh: '埋骨之地', - titleEn: 'Burial Grounds', - untilMs: now + 3600, - }) + expect(worldPanels.areaBanner).toBeNull() // 8. Safe fallbacks for null/undefined/empty inputs without throwing TypeError expect(getLevelNames(null as any)).toEqual({ nameZh: 'Unknown Area', nameEn: 'Unknown Area' }) @@ -606,23 +602,23 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { const unlearnedLinesEn = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!, 'ENG') expect(unlearnedLinesEn[0]).toEqual({ text: 'Fire Ball', color: 'gold', font: 'fontexocet10', align: 'center' }) // Required Level: red when character level < reqlevel - const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要角色等级: 12')) + const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要等級:12')) expect(reqLevelLine).toBeDefined() expect(reqLevelLine?.color).toBe('red') // Prerequisites: red when unmet const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈')) expect(prereqLine).toBeDefined() expect(prereqLine?.color).toBe('red') - // Unlearned skill MUST NOT include [下一等级] preview - expect(unlearnedLines.some(l => l.text.includes('[下一等级]'))).toBe(false) + // Unlearned skill MUST NOT include 下一等級 preview + expect(unlearnedLines.some(l => l.text.includes('下一等級'))).toBe(false) // 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1) const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1) expect(fireBolt).not.toBeNull() const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!) // When level is met, reqlevel line is omitted from tooltip - expect(fireBoltLines.some(l => l.text.includes('需要角色等级'))).toBe(false) - expect(fireBoltLines.some(l => l.text.includes('[下一等级]'))).toBe(false) + expect(fireBoltLines.some(l => l.text.includes('需要等級'))).toBe(false) + expect(fireBoltLines.some(l => l.text.includes('下一等級'))).toBe(false) // 3. Learned skill (slvl = 1) with next-level preview in green const learnedBuild = createInitialState('sor') @@ -631,11 +627,11 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(learnedFireBolt).not.toBeNull() const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!) - const currLevelLine = learnedLines.find(l => l.text.includes('当前技能等级: 1')) + const currLevelLine = learnedLines.find(l => l.text.includes('目前技能等級:1')) expect(currLevelLine).toBeDefined() expect(currLevelLine?.color).toBe('white') - const nextLevelHeader = learnedLines.find(l => l.text.includes('[下一等级] (Lv 2)')) + const nextLevelHeader = learnedLines.find(l => l.text.includes('下一等級')) expect(nextLevelHeader).toBeDefined() expect(nextLevelHeader?.color).toBe('green') @@ -643,30 +639,27 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害')) expect(nextLevelDmg).toBeDefined() - // 4. Maxed skill (hardPoints === 20): omits [下一等级] and shows max allocation notice in gold + // 4. Maxed skill (hardPoints === 20): omits 下一等級 const maxedBuild = createInitialState('sor') allocatePoint(maxedBuild, 36, 20) const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20) expect(maxedFireBolt).not.toBeNull() const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!) - expect(maxedLines.some(l => l.text.includes('[下一等级]'))).toBe(false) - const maxedNotice = maxedLines.find(l => l.text.includes('[已达技能点投入上限 (20/20)]')) - expect(maxedNotice).toBeDefined() - expect(maxedNotice?.color).toBe('gold') + expect(maxedLines.some(l => l.text.includes('下一等級'))).toBe(false) // 5. Synergy section formatting const fireballBuild = createInitialState('sor') allocatePoint(fireballBuild, 36) // Fire Bolt +1 const fbVm = buildTooltipViewModel(fireballBuild, 47, 12) const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!) - const synHeader = fbLines.find(l => l.text.includes('[协同技能加成]')) + const synHeader = fbLines.find(l => l.text.includes('額外的對')) expect(synHeader).toBeDefined() - expect(synHeader?.color).toBe('blue') - const synFireBolt = fbLines.find(l => l.text.includes('火彈: 每个有效技能点增加 14% 伤害')) + expect(synHeader?.color).toBe('white') + const synFireBolt = fbLines.find(l => l.text.includes('火彈: +14% 每等級傷害')) expect(synFireBolt).toBeDefined() expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point) - const synMeteor = fbLines.find(l => l.text.includes('隕石: 每个有效技能点增加 14% 伤害')) + const synMeteor = fbLines.find(l => l.text.includes('隕石: +14% 每等級傷害')) expect(synMeteor).toBeDefined() expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points) }) @@ -927,7 +920,7 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(inv.cursorItem).toBeNull() // 8. Horadric Cube 3x4 grid placement and pickup (`CUBE_GRID_ORIGIN`) - expect(CUBE_GRID_ORIGIN).toEqual({ x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 }) + expect(CUBE_GRID_ORIGIN).toEqual({ x: 80 + 118, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 }) // Cannot place Horadric Cube ('box') inside Horadric Cube const boxItem = inv.gridItems.find(p => p.item.code === 'box')!.item expect(worldPanels.clickCubeCell(0, 0, boxItem).handled).toBe(false)