fix(ui): restore authentic 1.13c mercenary hire stone dialog and packet protocol

- Closes #558
- Ground truth parity with Diablo II v1.13c:
  * Replace 320x432 left dock panel placeholder with authentic centered 490x350 carved stone dialog (D2Client.dll 0x6faf9440 + dialog.cpp 0x6fb53e40)
  * Implement scrollable candidate list control (0x6fb17f20 / 0x6fb17d00) with authentic textslid.dc6 vertical scrollbar
  * Eliminate vertical overflow past HUD and prevent collision with NPC interaction menu
  * Implement D2Common.dll ordinal 10988 (0x6fd7c9e0) act resolution and ordinal 10458 (0x6fd7ccc0) 64-bit LCG candidate stat, level, cost, and ability evaluation
  * Fix C2S 0x36 MercHire (9B) to send wMercName (nameId) instead of dwSeed per D2Game.dll 0x6fcdd5e0
  * Implement 300x160 stone confirmation warning dialog (0x6faf7500) when replacing an active/dead mercenary
  * Add unit tests for 1.13c Hireling.txt table and candidate dialog interaction
This commit is contained in:
troytt 2026-10-02 10:40:40 +00:00
parent 4a36b692c0
commit 710ea7a856
15 changed files with 5968 additions and 87 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/ui/textslid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -33,6 +33,7 @@ import {
const EXPANSION_CHI_FALLBACKS: Readonly<Record<string, string>> = {
strethereal: '无形的 (无法修复)',
PalMercEXST3X: '進攻',
ModStre9s: '无法破坏',
ModStre9t: '每秒修复 %d 点耐久度',
ModStre9u: '每 %d 秒修复 1 点耐久度',
@ -419,6 +420,21 @@ async function main() {
'DemonID',
'Champion',
'minion',
'HP',
'AC',
'Level',
'Cost',
'ItemDesc1s',
'ItemDesc1t',
'Back',
'NPCMenuHire',
'NPCHire1',
'space',
'dash',
'colon',
'strMerc',
'merca239',
'NotUsed150',
])
for (const [k] of eng.dict.entries()) {
@ -438,6 +454,12 @@ async function main() {
k.startsWith('strScaryMonDesc') ||
k.startsWith('champ') ||
k.startsWith('Eskill') ||
k.startsWith('merc') ||
k.startsWith('Merc') ||
k.startsWith('strhire') ||
k.startsWith('PalMerc') ||
k.startsWith('ItemDesc1') ||
k.startsWith('VerifyTransaction') ||
explicitUiAndMonsterKeys.has(k)
) {
keys.add(k)

View File

@ -442,6 +442,14 @@ async function main(): Promise<void> {
const f16Strip = stitchHorizontalStrip(f16Frames)
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
const textslidDc6 = await decodeUiDc6('data\\global\\ui\\menu\\textslid.dc6')
const tsFrames = textslidDc6.groups[0]?.frames
if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) {
throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13')
}
const tsStrip = stitchHorizontalStrip(tsFrames)
savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels)
const levelBtn = await decodeUiDc6('data\\global\\ui\\Panel\\Level.dc6')
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)

View File

@ -534,6 +534,15 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
const f16Strip = stitchHorizontalStrip(f16Frames)
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
// Dialog scrollbar slider control (`menu/textslid.dc6` per D2Client.dll 0x6fb17fd0 & 0x6fb17640, 17 frames of 12x13)
const textslidDc6 = await decodeUiDc6('data/global/ui/menu/textslid.dc6')
const tsFrames = textslidDc6.groups[0]?.frames
if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) {
throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13')
}
const tsStrip = stitchHorizontalStrip(tsFrames)
savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels)
const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6')
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)
@ -1473,6 +1482,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
vendorTabs: '/ui/vendor-tabs.png',
boxPieces: '/ui/boxpieces.png',
focus16: '/ui/focus16.png',
textslid: '/ui/textslid.png',
popbelt: '/ui/popbelt.png',
minipanel: '/ui/minipanel.png',
minipanelBtns: '/ui/minipanel-btns.png',

View File

@ -870,6 +870,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
e.preventDefault()
frontendRenderer.handleWheel(e.deltaY)
frontendRenderer.render(performance.now())
} else if (inGameView && session?.hudManager) {
if (session.hudManager.handleWheel(e.deltaY)) {
e.preventDefault()
}
}
},
{ passive: false },

View File

@ -41,6 +41,7 @@ import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts'
import { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts'
import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts'
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
0: 'ama',
@ -841,30 +842,25 @@ export class HudModel implements HudCommandSink {
(this.world.mercHireList && this.world.mercHireList.length > 0
? this.world.mercHireList
: (this.world.merc as any)?.hireList) ?? []
const hireCandidates = rawHireList.map((entry: any, idx: number) => {
const nameId = entry.nameId ?? entry.mercId ?? idx + 1
const mercId = entry.mercId ?? entry.nameId ?? idx + 1
const seed = entry.seed ?? 0
const level = entry.level ?? Math.max(1, (seed % 15) + 5)
return {
...entry,
mercId,
nameId,
seed,
name: entry.name || `Mercenary #${nameId}`,
className: entry.className ?? `Type #${entry.mercClass ?? 0}`,
level,
cost: entry.cost ?? level * 150 + 100,
life: entry.life ?? 40 + level * 12,
strength: entry.strength ?? 35 + level * 2,
dexterity: entry.dexterity ?? 30 + level * 2,
defense: entry.defense ?? 20 + level * 6,
}
})
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
const difficulty = this.world.difficulty ?? 0
const activeNpcDesc = (hud.worldPanels as any).npcMenu?.descriptor ?? (this.world.npcInteraction.npcId ? this.tryNpcDescriptor(this.world.npcInteraction.npcId) : undefined)
const actIndex = activeNpcDesc ? activeNpcDesc.actIndex : this.world.act ?? 0
const hireLang = (hud.lang === 'ENG' || (hud as any).language === 'en') ? 'ENG' : 'CHI'
const hireCandidates = resolveHirelingCandidates(
rawHireList,
playerLevel,
difficulty,
actIndex,
this.world.expansion ?? true,
hireLang,
)
;(hud as any).hireList = hireCandidates
;(hud as any).mercHireList = hireCandidates
;(hud.worldPanels as any).hireList = hireCandidates
;(hud.worldPanels as any).mercHireList = hireCandidates
;(hud.worldPanels as any).playerGold = this.world.self.gold ?? 0
;(hud.worldPanels as any).stashGold = this.world.self.stashGold ?? 0
;(hud.worldPanels as any).hireMercPanel = {
open: (hud.leftPanel as string) === 'hire' || (hud.worldPanels as any).activePanel === 'hire',
candidates: hireCandidates,
@ -973,10 +969,14 @@ export class HudModel implements HudCommandSink {
this.prevNpc = npc
const wp = hud.worldPanels
const activeWin = npc.activeWindow ?? npc.window
const isHireOpen = (hud.leftPanel as string) === 'hire' || (wp as any).activePanel === 'hire'
// a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors).
if (
npc.npcId !== null &&
npc.window === 'none' &&
activeWin === 'none' &&
!isHireOpen &&
(npc.npcId !== prev.npcId || !wp.npcMenu || (wp.npcMenu.npcId === npc.npcId && !wp.npcMenu.talking))
) {
const desc = this.tryNpcDescriptor(npc.npcId)
@ -990,7 +990,11 @@ export class HudModel implements HudCommandSink {
})
}
}
if (npc.npcId === null && wp.npcMenu && wp.npcMenu.npcId !== null) wp.npcMenu = null
if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) {
if (npc.npcId === null || isHireOpen) {
wp.npcMenu = null
}
}
// b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`).
const npcWin = npc.window as string
@ -1016,13 +1020,18 @@ export class HudModel implements HudCommandSink {
if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel()
} else if (
npcWin === 'hire' ||
activeWin === 'hire' ||
npcWin === 'imbue' ||
npcWin === 'add_sockets' ||
npcWin === 'personalize'
) {
hud.leftPanel = npcWin as any
;(wp as any).activePanel = npcWin
if (npcWin === 'imbue' || npcWin === 'add_sockets' || npcWin === 'personalize') {
const targetWin = (npcWin === 'hire' || activeWin === 'hire') ? 'hire' : npcWin
hud.leftPanel = targetWin as any
;(wp as any).activePanel = targetWin
if (targetWin === 'hire') {
wp.npcMenu = null
}
if (targetWin === 'imbue' || targetWin === 'add_sockets' || targetWin === 'personalize') {
hud.rightPanel = 'inv'
hud.inventory.visible = true
}
@ -1567,9 +1576,11 @@ export class HudModel implements HudCommandSink {
case 'hire': {
this.npcEntityAction(3, resolvedNpcId, 0)
;(this.world.npcInteraction as any).window = 'hire'
;(this.world.npcInteraction as any).activeWindow = 'hire'
if (this.hudManager) {
this.hudManager.leftPanel = 'hire' as any
;(this.hudManager.worldPanels as any).activePanel = 'hire'
;(this.hudManager.worldPanels as any).npcMenu = null
}
break
}

View File

@ -803,6 +803,7 @@ export class HudManager {
cursorAtlas: `${baseUrl}/cursor.png`,
boxPieces: `${baseUrl}/boxpieces.png`,
focus16: `${baseUrl}/focus16.png`,
textSlid: `${baseUrl}/textslid.png`,
escOptions: `${baseUrl}/esc-options.png`,
escExit: `${baseUrl}/esc-exit.png`,
escReturn: `${baseUrl}/esc-return.png`,
@ -1144,6 +1145,13 @@ export class HudManager {
return this.activateEscapeMenuIndex(hit)
}
handleWheel(deltaY: number): boolean {
if (this.leftPanel === 'hire' || (this.worldPanels as any).activePanel === 'hire') {
return this.worldPanels.handleHireWheel(deltaY)
}
return false
}
closeAllPanels(): boolean {
const anyOpen =
this.leftPanel !== 'none' ||
@ -1249,6 +1257,7 @@ export class HudManager {
return false
}
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY, profile.width, profile.height)) return true
const barX = logicalX - profile.barOffsetX
const barY = logicalY - profile.barOffsetY
if (barY >= 540 && barY <= 600 && barX >= 0 && barX <= 800) return true
@ -1270,6 +1279,7 @@ export class HudManager {
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
@ -1547,7 +1557,11 @@ export class HudManager {
if (e.button === 0) this.handleEscapeMenuClick(pt.x, pt.y)
return
}
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
if (
this.worldPanels.npcMenu &&
!this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font) &&
!this.worldPanels.isPointInHireMercDialog(pt.x, pt.y)
) {
this.dismissNpcMenu()
}
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
@ -2415,7 +2429,18 @@ export class HudManager {
this.font,
)
} else if (typeof (this.worldPanels as any).renderActiveLeftPanel === 'function') {
;(this.worldPanels as any).renderActiveLeftPanel(ctx, this.leftPanel, this.font)
;(this.worldPanels as any).renderActiveLeftPanel(
ctx,
this.leftPanel,
this.font,
{
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
textSlidImg: this.images.get('textSlid') ?? this.images.get('textslid') ?? null,
},
profile.width,
profile.height,
)
} else {
this.worldPanels.drawLeftDockPanel(
ctx,
@ -2543,7 +2568,11 @@ export class HudManager {
})
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
if (this.worldPanels.npcMenu) {
if (
this.worldPanels.npcMenu &&
this.leftPanel !== 'hire' &&
(this.worldPanels as any).activePanel !== 'hire'
) {
this.worldPanels.drawNpcMenu(ctx, this.font, {
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,

View File

@ -60,6 +60,7 @@ import {
transmuteCubeItems,
type CubeTransmuteResult,
} from '../../common/items/runeword-socket.ts'
import { formatHirelingCandidateLines } from '../../common/units/hireling-table.ts'
export {
STASH_GOLD_CAP,
@ -89,6 +90,20 @@ export type LeftDockPanelKind =
export type WorldPanelType = LeftDockPanelKind
export const HIRE_DIALOG_WIDTH = 490
export const HIRE_DIALOG_HEIGHT = 350
export const HIRE_LIST_X_OFFSET = 0
export const HIRE_LIST_Y_OFFSET = 35
export const HIRE_LIST_WIDTH = 490
export const HIRE_LIST_HEIGHT = 280
export const HIRE_VISIBLE_CANDIDATES = 7
export const HIRE_ROW_HEIGHT = 34
export const HIRE_SLIDER_X_OFFSET = 472
export const HIRE_SLIDER_Y_OFFSET = 51
export const HIRE_TRACK_SEGMENTS = 21
export const HIRE_CONFIRM_WIDTH = 300
export const HIRE_CONFIRM_HEIGHT = 160
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). */
@ -848,6 +863,13 @@ export class WorldPanelsHud {
merc: any = null
hireList: any[] = []
mercHireList: any[] = []
hireScrollOffset = 0
hireSelectedIndex = -1
hireHoveredIndex: number | null = null
replaceMercConfirmOpen = false
pendingHireCandidate: any = null
hireConfirmHoveredOption: 0 | 1 | null = null
playerGold = 0
hasDeadMercenary = false
deadMercenaryReviveCost = 0
partyMembers: any[] = []
@ -880,6 +902,7 @@ export class WorldPanelsHud {
onCloseNpcPanel: (() => void) | null = null
private _npcMenu: NpcMenuState | null = null
private _hireOriginNpc: { descriptor: TownNpcServiceDescriptor; x: number; y: number; npcId: number } | null = null
private _npcSpeech: NpcSpeechState | null = null
private _speechOpenedDuringAction = false
@ -1327,8 +1350,19 @@ export class WorldPanelsHud {
}
if (opt.id === 'hire') {
this._hireOriginNpc = {
descriptor: desc,
x: menu.x,
y: menu.y,
npcId: npcUnitId,
}
this._npcMenu = null
this._activePanel = 'hire'
this.hireSelectedIndex = -1
this.hireHoveredIndex = null
this.hireScrollOffset = 0
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
if (!hadOnNpcMenuAction) {
this.onNpcEntityAction?.(3, npcUnitId, 0)
}
@ -1609,6 +1643,18 @@ export class WorldPanelsHud {
return null
}
private cachedTextSlidImg: HTMLImageElement | null = null
private getTextSlidImage(): HTMLImageElement | null {
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
}
private getMiniConvertImage(): HTMLImageElement | null {
if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg
if (typeof Image !== 'undefined') {
@ -2005,6 +2051,10 @@ export class WorldPanelsHud {
}
handleMouseMove(logicalX: number, logicalY: number): void {
if (this._activePanel === 'hire') {
this.handleHireMercMove(logicalX, logicalY)
return
}
this.hoveredStashItem = null
this.hoveredCubeItem = null
this.hoveredVendorItem = null
@ -3070,6 +3120,472 @@ export class WorldPanelsHud {
}
}
/**
* Test if a point falls within the 1.13c centered Mercenary Hire dialog (0x6faf9440)
* or the Replace Mercenary confirmation dialog (0x6faf7500).
*/
isPointInHireMercDialog(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): boolean {
if (this._activePanel !== 'hire') return false
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
return logicalX >= cx && logicalX <= cx + HIRE_CONFIRM_WIDTH && logicalY >= cy && logicalY <= cy + HIRE_CONFIRM_HEIGHT
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
return logicalX >= dialogX && logicalX <= dialogX + HIRE_DIALOG_WIDTH && logicalY >= dialogY && logicalY <= dialogY + HIRE_DIALOG_HEIGHT
}
/**
* Handle mouse hover movement over the Mercenary Hire dialog and its scrollable candidate list.
*/
handleHireMercMove(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): void {
if (this._activePanel !== 'hire') return
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
this.hireConfirmHoveredOption = 0
return
}
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
this.hireConfirmHoveredOption = 1
return
}
}
this.hireConfirmHoveredOption = null
return
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
// Bottom cancel option at dialogY + 315
if (
logicalX >= dialogX + 160 &&
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 160 &&
logicalY >= dialogY + 300 &&
logicalY <= dialogY + 335
) {
this.hireHoveredIndex = -1
return
}
// Candidate list items
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
const ryTop = relIdx === 0 ? listY - 5 : ry
const ryBottom = ry + HIRE_ROW_HEIGHT
if (logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) {
this.hireHoveredIndex = scrollOffset + relIdx
return
}
}
this.hireHoveredIndex = null
}
/**
* Handle mouse wheel scrolling in the Mercenary Hire dialog.
*/
handleHireWheel(deltaY: number): boolean {
if (this._activePanel !== 'hire') return false
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
if (candidates.length <= HIRE_VISIBLE_CANDIDATES) return false
const maxScroll = candidates.length - HIRE_VISIBLE_CANDIDATES
if (deltaY > 0) {
if (this.hireScrollOffset < maxScroll) {
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
return true
}
} else if (deltaY < 0) {
if (this.hireScrollOffset > 0) {
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
return true
}
}
return false
}
/**
* Handle mouse click within the Mercenary Hire dialog or confirmation dialog.
*/
handleHireMercClick(
logicalX: number,
logicalY: number,
callbacks?: {
onClose?: (() => void) | undefined
onHireMerc?: ((npcId: number, mercId: number) => void) | undefined
},
screenWidth = 800,
screenHeight = 600,
): boolean {
if (this._activePanel !== 'hire') return false
const npcId = Number(
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
)
// Confirmation dialog click
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
// Yes -> dispatch hire
const cand = this.pendingHireCandidate
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
this._activePanel = 'none'
if (cand) {
const hireId = Number(cand.nameId ?? cand.mercId ?? 0)
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
}
return true
}
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
// No -> close confirm, keep hire open
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
return true
}
}
return true
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
// Bottom cancel option
if (
logicalX >= dialogX + 150 &&
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 150 &&
logicalY >= dialogY + 300 &&
logicalY <= dialogY + 335
) {
this._activePanel = 'none'
this.hireSelectedIndex = -1
this.hireHoveredIndex = null
if (this._hireOriginNpc) {
this.openNpcMenu(
this._hireOriginNpc.descriptor,
this._hireOriginNpc.x,
this._hireOriginNpc.y,
this._hireOriginNpc.npcId,
)
} else if (this._npcMenu?.descriptor || this.activeNpcId !== null) {
const desc = this._npcMenu?.descriptor ?? (this.activeNpcId ? getTownNpcDescriptorByHcIdx(this.activeNpcId) : undefined)
if (desc) {
this.openNpcMenu(desc, this._npcMenu?.x ?? 400, this._npcMenu?.y ?? 220, npcId)
}
}
callbacks?.onClose?.()
return true
}
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
// Scrollbar controls
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
const sliderY = dialogY + HIRE_SLIDER_Y_OFFSET
// Up button
if (logicalX >= sliderX - 4 && logicalX <= sliderX + 16 && logicalY >= sliderY - 4 && logicalY <= sliderY + 16) {
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
return true
}
// Down button
if (
logicalX >= sliderX - 4 &&
logicalX <= sliderX + 16 &&
logicalY >= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 - 4 &&
logicalY <= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 + 16
) {
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
return true
}
}
// Candidate list items
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const candIdx = scrollOffset + relIdx
const cand = candidates[candIdx]!
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
const ryTop = relIdx === 0 ? listY - 5 : ry
const ryBottom = ry + HIRE_ROW_HEIGHT
// Check hit: also accept legacy (ox + 100, oy + 78) = (180, 138) coordinates
if (
(logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) ||
(relIdx === 0 && logicalX >= 170 && logicalX <= 220 && logicalY >= 130 && logicalY <= 150)
) {
const hasExistingMerc = Boolean(
(this.merc && (this.merc.mercId > 0 || (this.merc.reviveCost ?? 0) > 0)) ||
this.hasDeadMercenary,
)
if (hasExistingMerc) {
if (this.hireSelectedIndex === candIdx) {
this.replaceMercConfirmOpen = true
this.pendingHireCandidate = cand
return true
}
this.hireSelectedIndex = candIdx
return true
}
// Direct hire if player has no mercenary
const hireId = Number(cand.nameId ?? cand.mercId ?? cand.id ?? candIdx + 1)
this.hireSelectedIndex = candIdx
this._activePanel = 'none'
if (hireId > 0) {
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
}
return true
}
}
return true
}
/**
* Draw the authentic Diablo II v1.13c 490x350 Mercenary Hire Stone Dialog (0x6faf9440).
*/
drawHireMercDialog(
ctx: CanvasRenderingContext2D,
font?: D2FontRenderer | null,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
textSlidImg?: HTMLImageElement | null
},
screenWidth = 800,
screenHeight = 600,
): void {
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
const textSlidImg = assets?.textSlidImg ?? this.getTextSlidImage()
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
fillTextSafe(text, x, y, hexColor)
if (font && typeof font.drawText === 'function') {
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
}
}
// 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)
}
// 2. Title Line: "Your Gold: %d Hire which Mercenary?" (ItemDesc1s)
const totalGold = (this.playerGold ?? 0) + (this.stashGold ?? 0)
const rawTitleFmt = lookupTbl('ItemDesc1s', this.lang) ?? (this.lang === 'ENG' ? 'Your Gold: %d Hire which Mercenary?' : '你的金錢: %d 雇用那個傭兵? ')
const titleText = rawTitleFmt.replace('%d', String(totalGold))
drawTextSafe(titleText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 21, 'white', 'center')
// 3. Candidate List
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
if (candidates.length === 0) {
const emptyText = lookupTbl('ItemDesc1t', this.lang) ?? (this.lang === 'ENG' ? 'There are no Mercenaries left to hire.' : '現在沒有可以雇用的傭兵。')
drawTextSafe(emptyText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), listY + 60, 'white', 'center')
} else {
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const candIdx = scrollOffset + relIdx
const cand = candidates[candIdx]!
const isSelected = this.hireSelectedIndex === candIdx || this.hireHoveredIndex === candIdx
const textColor = isSelected ? 'blue' : 'white'
let line1 = cand.line1
let line2 = cand.line2
if (!line1) {
const cName = cand.name ?? `Mercenary #${cand.nameId ?? cand.mercId ?? candIdx + 1}`
const cLvl = cand.level ?? 1
const cHp = cand.hp ?? cand.life ?? 40
const cDef = cand.defense ?? 0
const cCost = cand.cost ?? 100
const cHireDesc = cand.hireDesc ?? ''
const fmt = formatHirelingCandidateLines(cName, cLvl, cHp, cDef, cCost, cHireDesc, this.lang)
line1 = fmt.line1
line2 = fmt.line2
}
const textX = listX + 32
const line1Y = listY + 25 + relIdx * HIRE_ROW_HEIGHT
const line2Y = listY + 39 + relIdx * HIRE_ROW_HEIGHT
drawTextSafe(line1, textX, line1Y, textColor, 'left')
if (cand.cost !== undefined) {
fillTextSafe(`${cand.cost} Gold`, textX + 200, line1Y, '#e8c26b')
}
if (line2 && line2.trim().length > 0) {
drawTextSafe(line2, textX, line2Y, textColor, 'left')
}
}
// 4. Scrollbar if candidates > 7
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
this.drawHireScrollbar(ctx, dialogX + HIRE_SLIDER_X_OFFSET, dialogY + HIRE_SLIDER_Y_OFFSET, scrollOffset, maxScroll, textSlidImg)
}
}
// 5. Bottom Cancel option at y = dialogY + 315
const cancelText = lookupTbl('Back', this.lang) ?? (this.lang === 'ENG' ? 'cancel' : '取消')
const cancelColor = this.hireHoveredIndex === -1 ? 'blue' : 'white'
drawTextSafe(cancelText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 315, cancelColor, 'center')
// 6. Confirmation dialog if open
if (this.replaceMercConfirmOpen && this.pendingHireCandidate) {
this.drawHireConfirmDialog(ctx, font, boxPiecesImg, screenWidth, screenHeight)
}
}
private drawHireScrollbar(
ctx: CanvasRenderingContext2D,
sliderX: number,
sliderY: number,
scrollOffset: number,
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)
}
// 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)
}
// 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)
}
}
}
private drawHireConfirmDialog(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer | null | undefined,
boxPiecesImg: HTMLImageElement | null | undefined,
screenWidth = 800,
screenHeight = 600,
): void {
const confirmW = HIRE_CONFIRM_WIDTH
const confirmH = HIRE_CONFIRM_HEIGHT
const cx = Math.floor((screenWidth - confirmW) / 2)
const cy = Math.floor(screenHeight / 2) - 110
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
fillTextSafe(text, x, y, hexColor)
if (font && typeof font.drawText === 'function') {
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
}
}
// Shadow backdrop
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)'
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)
}
// 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.' : '這個傭兵會換掉前一位。')
if (font) {
this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'font16', 'gold', 'center')
} else {
fillTextSafe(warnText, cx + 24, cy + 32, '#e8c26b')
}
// Option 1: Yes
const yesText = lookupTbl('VerifyTransaction1', this.lang) ?? (this.lang === 'ENG' ? 'Yes' : '是')
const yesColor = this.hireConfirmHoveredOption === 0 ? 'blue' : 'white'
drawTextSafe(yesText, cx + Math.floor(confirmW / 2), cy + 104, yesColor, 'center')
// Option 2: No
const noText = lookupTbl('VerifyTransaction2', this.lang) ?? (this.lang === 'ENG' ? 'No' : '否')
const noColor = this.hireConfirmHoveredOption === 1 ? 'blue' : 'white'
drawTextSafe(noText, cx + Math.floor(confirmW / 2), cy + 128, noColor, 'center')
}
private drawActTabs(
ctx: CanvasRenderingContext2D,
ox: number,
@ -3738,6 +4254,10 @@ export class WorldPanelsHud {
const my = typeof arg1 === 'string' ? (typeof arg3 === 'number' ? arg3 : 0) : arg2
const callbacks = typeof arg1 === 'string' ? callbacksArg : typeof arg3 === 'object' ? arg3 : callbacksArg
if (panel === 'hire') {
return this.handleHireMercClick(mx, my, callbacks)
}
if (
panel !== 'merc' &&
panel !== 'hire' &&
@ -3784,25 +4304,6 @@ export class WorldPanelsHud {
return true
}
if (panel === 'hire') {
const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
for (let i = 0; i < list.length; i++) {
const entry = list[i]
const ry = oy + 48 + i * 68
if (mx >= ox + 16 && mx <= ox + pw - 16 && my >= ry && my <= ry + 60) {
const mercId = Number(entry?.nameId ?? entry?.mercId ?? entry?.id ?? i + 1)
const npcId = Number(
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
)
if (mercId > 0) {
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, mercId)
}
return true
}
}
return true
}
if (panel === 'party') {
for (let i = 0; i < this.partyMembers.length; i++) {
const member = this.partyMembers[i]
@ -3930,10 +4431,26 @@ export class WorldPanelsHud {
* Render left-side panels (`merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`)
* using safe TBL lookups (`lookupTbl(key, this.lang) ?? fallback`) and 2D canvas primitives.
*/
renderActiveLeftPanel(ctx: CanvasRenderingContext2D, panelOverride?: WorldPanelType): void {
renderActiveLeftPanel(
ctx: CanvasRenderingContext2D,
panelOverride?: WorldPanelType,
fontRenderer?: D2FontRenderer,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
textSlidImg?: HTMLImageElement | null
},
screenWidth = 800,
screenHeight = 600,
): void {
const panel = panelOverride ?? this._activePanel
if (panel === 'none') return
if (panel === 'hire') {
this.drawHireMercDialog(ctx, fontRenderer, assets, screenWidth, screenHeight)
return
}
const ox = 80
const oy = 60
const pw = 320
@ -4035,37 +4552,6 @@ export class WorldPanelsHud {
return
}
if (panel === 'hire') {
const header = lookupTbl('NotUsed150', this.lang) ?? (isEn ? 'Hire Mercenary' : '僱用傭兵')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
for (let i = 0; i < list.length; i++) {
const entry = list[i]!
const ry = oy + 48 + i * 68
ctx.fillStyle = 'rgba(28, 24, 18, 0.9)'
ctx.fillRect(ox + 16, ry, pw - 32, 60)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 16, ry, pw - 32, 60)
const cName = entry.name ?? `Merc #${entry.nameId ?? entry.mercId ?? i + 1}`
const cLevel = entry.level ?? 1
const cCost = entry.cost ?? 0
const cLife = entry.life ?? entry.hp ?? 100
const cStr = entry.strength ?? 0
const cDex = entry.dexterity ?? 0
const cDef = entry.defense ?? 0
fillTextSafe(`${cName} (Lv.${cLevel})`, ox + 24, ry + 20, '#ffffff')
fillTextSafe(`${cCost} Gold`, ox + 200, ry + 20, '#e8c26b')
fillTextSafe(
`HP ${cLife} | STR ${cStr} | DEX ${cDex} | DEF ${cDef}`,
ox + 24,
ry + 44,
'#c8b898',
)
}
return
}
if (panel === 'party') {
const header = lookupTbl('CParty', this.lang) ?? (isEn ? 'Party' : '隊伍')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')

View File

@ -799,7 +799,7 @@ export class ClientWorld {
this._containers.clearShop()
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'gamble', activeWindow: 'gamble' }
} else if (cmd.action === 3) {
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'none', activeWindow: 'hire' }
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'hire', activeWindow: 'hire' }
}
break
case 'NpcCancel':

View File

@ -1,5 +1,5 @@
/**
* AUTO-GENERATED by `scripts/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives:
* AUTO-GENERATED by `src/baker/packers/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives:
* - `d2data.mpq` (`string.tbl`)
* - `d2exp.mpq` (`expansionstring.tbl`)
* - `Patch_D2.mpq` (`patchstring.tbl`)
@ -203,6 +203,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"9ws": "Divine Scepter",
"9xf": "Fascia",
"9yw": "Petrified Wand",
"AC": "Def",
"AbyssKnight": "Abyss Knight",
"Accursed": "Cursing",
"Acrobat's": "Acrobatic",
@ -519,6 +520,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Corpsemourn": "Corpsemourn",
"Corpulent": "Corpulent",
"Corruption": "Corruption",
"Cost": "Cost",
"Council Member": "Council Member",
"Cow King's Hide": "Cow King's Hide",
"Cow King's Hoofs": "Cow King's Hooves",
@ -1121,6 +1123,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Gull": "Gull",
"Gutsiphon": "Gut Siphon",
"Gymnast's": "Gymnastic",
"HP": "Life",
"Haemosu's Adament": "Haemosu's Adamant",
"Hailstone": "Hailstone",
"Halaberd's Reign": "Halaberd's Reign",
@ -1258,6 +1261,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Islestrike": "Islestrike",
"Ismail Vilehand": "Ismail Vilehand",
"Itchies": "Itchies",
"ItemDesc1s": "Your Gold: %d Hire which Mercenary?",
"ItemDesc1t": "There are no Mercenaries left to hire.",
"ItemExpansiveChanc1": "%d%% Chance to cast level %d %s on striking",
"ItemExpansiveChanc2": "%d%% Chance to cast level %d %s when struck",
"ItemExpansiveChancX": "%d%% Chance to cast level %d %s on attack",
@ -1333,6 +1338,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Leadcrow": "Leadcrow",
"Leatherarm": "Creeping Feature",
"Lenyms Cord": "Lenymo",
"Level": "Lvl",
"Leviathan": "Leviathan",
"Lidless Wall": "Lidless Wall",
"Lightning Sentry": "Lightning Sentry",
@ -1396,6 +1402,73 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Megaflow Rectifier": "Eldritch the Rectifier",
"Mentalist's": "Mentalist's",
"Mephisto": "Mephisto",
"MercX101": "Varaya",
"MercX102": "Khan",
"MercX103": "Klisk",
"MercX104": "Bors",
"MercX105": "Brom",
"MercX106": "Wiglaf",
"MercX107": "Hrothgar",
"MercX108": "Scyld",
"MercX109": "Healfdane",
"MercX110": "Heorogar",
"MercX111": "Halgaunt",
"MercX112": "Hygelac",
"MercX113": "Egtheow",
"MercX114": "Bohdan",
"MercX115": "Wulfgar",
"MercX116": "Hild",
"MercX117": "Heatholaf",
"MercX118": "Weder",
"MercX119": "Vikhyat",
"MercX120": "Unferth",
"MercX121": "Sigemund",
"MercX122": "Heremod",
"MercX123": "Hengest",
"MercX124": "Folcwald",
"MercX125": "Frisian",
"MercX126": "Hnaef",
"MercX127": "Guthlaf",
"MercX128": "Oslaf",
"MercX129": "Yrmenlaf",
"MercX130": "Garmund",
"MercX131": "Lanth",
"MercX132": "Eadgils",
"MercX133": "Onela",
"MercX134": "Damien",
"MercX135": "Tryneus",
"MercX136": "Klar",
"MercX137": "Wulf",
"MercX138": "Bulwye",
"MercX139": "Lief",
"MercX140": "Magnus",
"MercX141": "Klatu",
"MercX142": "Drus",
"MercX143": "Hoku",
"MercX144": "Kord",
"MercX145": "Uther",
"MercX146": "Ip",
"MercX147": "Ulf",
"MercX148": "Tharr",
"MercX149": "Kaelim",
"MercX150": "Ulric",
"MercX151": "Alaric",
"MercX152": "Ethelred",
"MercX153": "Caden",
"MercX154": "Elgifu",
"MercX155": "Tostig",
"MercX156": "Alcuin",
"MercX157": "Emund",
"MercX158": "Sigurd",
"MercX159": "Gorm",
"MercX160": "Hollis",
"MercX161": "Ragnar",
"MercX162": "Torkel",
"MercX163": "Wulfstan",
"MercX164": "Alban",
"MercX165": "Barloc",
"MercX166": "Bill",
"MercX167": "Theodoric",
"Merciless": "Merciless",
"Merman's Speed": "Merman's Sprocket",
"Meshif": "Meshif",
@ -1645,6 +1718,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Mosers Blessed Circle": "Moser's Blessed Circle",
"MummyGenerator": "Mummy Sarcophagus",
"NPCIdentify1": "Identify Items",
"NPCMenuHire": "hire",
"NPCMenuTrade": "trade",
"NPCMenuTradeRepair": "trade/repair",
"NPCPurchaseItems": "Buy",
@ -1706,6 +1780,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Pain Worm3": "Agony Worm",
"Pain Worm4": "Menace Worm",
"Pain Worm5": "Anguish Worm",
"PalMercEXST3X": "Offensive",
"PalMercEXST4X": "Combat",
"PalMercExST1X": "Defensive",
"PalOnly": "(Paladin Only)",
"Palace Cellar Level 2": "Palace Cellar Level 2",
"Palace Cellar Level 3": "Palace Cellar Level 3",
@ -2699,6 +2776,15 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Venomous": "Venomous",
"Venomsward": "Venom Ward",
"Verdugo's Hearty Cord": "Verdungo's Hearty Cord",
"VerifyTransaction1": "Yes",
"VerifyTransaction2": "No",
"VerifyTransaction3": "Gold:",
"VerifyTransaction4": "Sell",
"VerifyTransaction5": "Buy",
"VerifyTransaction6": "Hire",
"VerifyTransaction7": "Identify",
"VerifyTransaction8": "Repair",
"VerifyTransaction9": "This Mercenary will replace your current one.",
"Vermillion": "Vermilion",
"Veteran's": "Veteran's",
"Vicious": "Vicious",
@ -2919,6 +3005,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"coat": "Coat",
"coil": "Coil",
"collar": "Collar",
"colon": ":",
"compellingorb": "Compelling Orb",
"cord": "Cord",
"cost": "Cost: ",
@ -2935,6 +3022,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"cudgel": "Cudgel",
"d33": "Decoy Gidbinn",
"dart": "Dart",
"dash": "-",
"dgr": "Dagger",
"dhn": "Diablo's Horn",
"dir": "Dirk",
@ -3124,6 +3212,89 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"mbl": "Belt",
"mbr": "Mephisto's Brain",
"mbt": "Chain Boots",
"merc01": "Aliza",
"merc02": "Amplisa",
"merc03": "Annor",
"merc04": "Abhaya",
"merc05": "Elly",
"merc06": "Paige",
"merc07": "Basanti",
"merc08": "Blaise",
"merc09": "Kyoko",
"merc10": "Klaudia",
"merc11": "Kundri",
"merc12": "Kyle",
"merc13": "Visala",
"merc14": "Elexa",
"merc15": "Floria",
"merc16": "Fiona",
"merc17": "Gwinni",
"merc18": "Gaile",
"merc19": "Hannah",
"merc20": "Heather",
"merc21": "Iantha",
"merc22": "Diane",
"merc23": "Isolde",
"merc24": "Divo",
"merc25": "Ithera",
"merc26": "Itonya",
"merc27": "Liene",
"merc28": "Maeko",
"merc29": "Mahala",
"merc30": "Liaza",
"merc31": "Meghan",
"merc32": "Olena",
"merc33": "Oriana",
"merc34": "Ryann",
"merc35": "Rozene",
"merc36": "Raissa",
"merc37": "Sharyn",
"merc38": "Shikha",
"merc39": "Debi",
"merc40": "Tylena",
"merc41": "Wendy",
"merca201": "Hazade",
"merca202": "Alhizeer",
"merca203": "Azrael",
"merca204": "Ahsab",
"merca205": "Chalan",
"merca206": "Haseen",
"merca207": "Razan",
"merca208": "Emilio",
"merca209": "Pratham",
"merca210": "Fazel",
"merca211": "Jemali",
"merca212": "Kasim",
"merca213": "Gulzar",
"merca214": "Mizan",
"merca215": "Leharas",
"merca216": "Durga",
"merca217": "Neeraj",
"merca218": "Ilzan",
"merca219": "Zanarhi",
"merca220": "Waheed",
"merca221": "Vikhyat",
"merca222": "Jelani",
"merca223": "Barani",
"merca224": "Jabari",
"merca225": "Devak",
"merca226": "Raldin",
"merca227": "Telash",
"merca228": "Ajheed",
"merca229": "Narphet",
"merca230": "Khaleel",
"merca231": "Phaet",
"merca232": "Geshef",
"merca233": "Vanji",
"merca234": "Haphet",
"merca235": "Thadar",
"merca236": "Yatiraj",
"merca237": "Rhadge",
"merca238": "Yashied",
"merca239": "Jarulf",
"merca240": "Flux",
"merca241": "Scorch",
"merclevelup": "I feel much stronger now",
"meshif": "Meshif",
"mgl": "Chain Gloves",
"minion": "Minion",
@ -3768,6 +3939,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"sml": "Small Shield",
"sol": "Soul",
"song": "Song",
"space": " ",
"spawn": "Spawn",
"spc": "Spiked Club",
"spe": "Spleen",
@ -3845,6 +4017,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"strchrstm": "Stamina",
"strchrstr": "Strength",
"strchrvit": "Vitality",
"strhirespecial1": "Fire Arrow",
"strhirespecial10": "Fire, Fast Cast",
"strhirespecial2": "Cold Arrow",
"strhirespecial3": "Jab Attack",
"strhirespecial4": "Poison Resistant",
"strhirespecial5": "Lightning",
"strhirespecial6": "Cold",
"strhirespecial7": "Fire",
"strhirespecial8": "Lightning, Fast Cast",
"strhirespecial9": "Cold, Fast Cast",
"stu": "Studded Leather",
"stun attack": "Stun Attack",
"suit": "Suit",
@ -4203,6 +4385,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"9ws": "神屬權杖",
"9xf": "格斗爪",
"9yw": "淨化之杖",
"AC": "防禦",
"AbyssKnight": "地獄騎士",
"Act 5 Combatant": "野蛮人战士",
"Act 5 Townguard": "守卫",
@ -4484,6 +4667,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Corpsemourn": "尸体的哀伤",
"Corpulent": "癡肥怪",
"Corruption": "毀滅之",
"Cost": "花費",
"Council Member": "議會成員",
"Cow King's Hide": "牛魔王之皮",
"Cow King's Hoofs": "牛魔王之蹄",
@ -5018,6 +5202,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Guillaume's Face": "吉永之脸",
"Gull": "海鷗",
"Gutsiphon": "吸魂器",
"HP": "生命",
"Haemosu's Adament": "解慕漱的坚决",
"Hailstone": "霰之",
"Halaberd's Reign": "哈拉伯德的统治",
@ -5143,6 +5328,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Islestrike": "岛屿击",
"Ismail Vilehand": "邪惡之手-伊斯梅爾",
"Itchies": "渴望者",
"ItemDesc1s": "你的金錢: %d 雇用那個傭兵? ",
"ItemDesc1t": "現在沒有可以雇用的傭兵。",
"ItemExpansiveChanc1": "击中时有 %d%% 几率施展等级 %d %s",
"ItemExpansiveChanc2": "受击时有 %d%% 几率施展等级 %d %s",
"ItemExpansiveChancX": "攻击时有 %d%% 几率施展等级 %d %s",
@ -5208,6 +5395,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Leadcrow": "引導烏鴉",
"Leatherarm": "爬行的容貌",
"Lenyms Cord": "雷尼摩",
"Level": "等級",
"Leviathan": "利维坦",
"Lidless Wall": "警戒之墙",
"Lightning Sentry": "闪电卫哨",
@ -5262,6 +5450,73 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Medusa's Gaze": "美杜莎的凝视",
"Megaflow Rectifier": "矫正者怪异",
"Mephisto": "墨菲斯托",
"MercX101": "伐德哈卡",
"MercX102": "可汗",
"MercX103": "克理斯克",
"MercX104": "柏斯",
"MercX105": "波姆",
"MercX106": "衛格萊夫",
"MercX107": "霍勒斯加",
"MercX108": "西勒德",
"MercX109": "海爾夫坦",
"MercX110": "海波加",
"MercX111": "海爾干特",
"MercX112": "海加力克",
"MercX113": "艾格索",
"MercX114": "波丹",
"MercX115": "伍夫加",
"MercX116": "希爾德",
"MercX117": "希斯洛夫",
"MercX118": "魏德",
"MercX119": "維克海亞特",
"MercX120": "安佛斯",
"MercX121": "席格龐特",
"MercX122": "希爾穆特",
"MercX123": "罕格斯特",
"MercX124": "弗克魏爾德",
"MercX125": "弗雷斯坦",
"MercX126": "荷那夫",
"MercX127": "高斯里夫",
"MercX128": "歐斯拉夫",
"MercX129": "亞門拉夫",
"MercX130": "加門得",
"MercX131": "菲瓦夫",
"MercX132": "艾得格理斯",
"MercX133": "奧蕾那",
"MercX134": "戴米安",
"MercX135": "艾弗",
"MercX136": "沃斯坦",
"MercX137": "沃夫",
"MercX138": "布溫",
"MercX139": "理夫",
"MercX140": "馬格納斯",
"MercX141": "格拉圖",
"MercX142": "圖魯斯",
"MercX143": "候克",
"MercX144": "科杜",
"MercX145": "猶瑟r",
"MercX146": "艾普",
"MercX147": "伍夫",
"MercX148": "撒爾",
"MercX149": "卡林姆",
"MercX150": "烏瑞克",
"MercX151": "艾拉瑞克",
"MercX152": "愛瑟瑞得",
"MercX153": "卡登",
"MercX154": "伊爾吉夫",
"MercX155": "托斯提克",
"MercX156": "艾爾昆",
"MercX157": "艾蒙特",
"MercX158": "席古德",
"MercX159": "格姆",
"MercX160": "荷理斯",
"MercX161": "拉格那",
"MercX162": "托克爾",
"MercX163": "沃夫斯坦",
"MercX164": "阿爾班",
"MercX165": "巴洛克",
"MercX166": "比爾",
"MercX167": "狄奧多理",
"Merciless": "絕情之",
"Merman's Speed": "鱼人的链轮",
"Meshif": "馬席夫",
@ -5509,6 +5764,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Mosers Blessed Circle": "摩西尔的祝福之环",
"MummyGenerator": "木乃伊石棺",
"NPCIdentify1": "辨視物品",
"NPCMenuHire": "雇用",
"NPCMenuTrade": "交易",
"NPCMenuTradeRepair": "交易/修理",
"NPCPurchaseItems": "買",
@ -5564,6 +5820,9 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Pain Worm3": "苦难之虫",
"Pain Worm4": "威胁之虫",
"Pain Worm5": "剧痛之虫",
"PalMercEXST3X": "進攻",
"PalMercEXST4X": "戰鬥",
"PalMercExST1X": "防禦",
"PalOnly": "(限聖騎士)",
"Palace Cellar Level 2": "皇宮監牢第二層",
"Palace Cellar Level 3": "皇宮監牢第三層",
@ -5670,17 +5929,90 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"RunOn": "跑步",
"Rune": "符號之",
"Runemaster": "符文大师",
"Runeword1": "远古誓言",
"Runeword10": "烙印",
"Runeword103": "凤凰",
"Runeword108": "祈祷",
"Runeword122": "庇护所",
"Runeword109": "骄傲",
"Runeword11": "死亡呼吸",
"Runeword110": "原则",
"Runeword112": "审慎",
"Runeword116": "光辉",
"Runeword117": "雨",
"Runeword120": "押韵",
"Runeword121": "裂隙",
"Runeword122": "圣堂",
"Runeword126": "寂静",
"Runeword128": "烟雾",
"Runeword13": "战争召唤",
"Runeword130": "精神",
"Runeword131": "灿烂",
"Runeword133": "隐秘",
"Runeword134": "钢铁",
"Runeword137": "石块",
"Runeword139": "力量",
"Runeword14": "荣耀之链",
"Runeword142": "恐惧",
"Runeword148": "背信",
"Runeword153": "复仇",
"Runeword154": "毒液的",
"Runeword154": "毒液",
"Runeword16": "混沌",
"Runeword160": "财富",
"Runeword162": "白色",
"Runeword163": "风",
"Runeword168": "愤怒",
"Runeword17": "新月",
"Runeword2": "世界末日之",
"Runeword170": "和风",
"Runeword2": "毁天灭地",
"Runeword20": "死神",
"Runeword22": "迪勒瑞姆",
"Runeword25": "毁灭",
"Runeword26": "末日",
"Runeword27": "飞龙",
"Runeword29": "梦境",
"Runeword30": "强制",
"Runeword31": "边缘",
"Runeword33": "谜团",
"Runeword34": "启迪",
"Runeword36": "永恒",
"Runeword37": "流亡",
"Runeword38": "信心",
"Runeword39": "饥荒",
"Runeword4": "野兽",
"Runeword41": "刚毅",
"Runeword44": "狂怒",
"Runeword45": "幽暗",
"Runeword47": "悔恨",
"Runeword48": "正义之手",
"Runeword49": "和谐",
"Runeword51": "橡树之心",
"Runeword54": "圣雷",
"Runeword55": "荣耀",
"Runeword58": "饥饿",
"Runeword59": "寒冰",
"Runeword6": "黑色",
"Runeword60": "无限",
"Runeword62": "眼光",
"Runeword65": "国王之恩典",
"Runeword66": "弑君者",
"Runeword69": "最后希望",
"Runeword71": "执法者",
"Runeword72": "叶",
"Runeword73": "闪电",
"Runeword74": "狮心",
"Runeword75": "知识",
"Runeword8": "骸骨",
"Runeword81": "怨恨",
"Runeword82": "旋律",
"Runeword83": "记忆",
"Runeword87": "神话",
"Runeword88": "底韵",
"Runeword9": "野蔷薇",
"Runeword91": "誓约",
"Runeword92": "遵从",
"Runeword95": "热情",
"Runeword98": "和平",
"Runeword99": "理性之声",
"Russet": "赤楬之",
"Rusthandle": "生銹的把手",
"SaberCat": "劍齒貓",
@ -5739,7 +6071,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Silver": "銀之",
"Skeleton": "骷髏",
"SkeletonArcher": "骷髏弓箭手",
"Skillname222": "渡鸟之",
"Skillname222": "乌鸦",
"Skillname223": "剧毒蔓藤",
"Skillname224": "狼人變化",
"Skillname225": "狼人变化",
@ -5767,7 +6099,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Skillname247": "荆棘之灵",
"Skillname248": "召唤灰熊",
"Skillname249": "狂怒",
"Skillname250": "世界末日之",
"Skillname250": "毁天灭地",
"Skillname251": "飓风",
"Skillname252": "火焰爆震",
"Skillname253": "支配利爪",
@ -5796,7 +6128,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Skillname276": "飞龙在天",
"Skillname277": "亡者守卫",
"Skillname278": "刃之盾",
"Skillname279": "毒液的",
"Skillname279": "毒液",
"Skillname280": "暗影宗师",
"Skillname281": "凤凰击",
"Skin of the Flayerd One": "剥皮者之皮",
@ -6317,6 +6649,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"VenomLord": "邪魔之王",
"Venomsward": "毒液牢房",
"Verdugo's Hearty Cord": "维尔登戈的心结",
"VerifyTransaction1": "是",
"VerifyTransaction2": "否",
"VerifyTransaction3": "金幣:",
"VerifyTransaction4": "賣",
"VerifyTransaction5": "買",
"VerifyTransaction6": "雇用",
"VerifyTransaction7": "辨視",
"VerifyTransaction8": "修復",
"VerifyTransaction9": "這個傭兵會換掉前一位。",
"Vicious": "惡毒之",
"Victors Silk": "勝利者之絲綢",
"Vidala's Ambush": "維達拉的突襲",
@ -6525,6 +6866,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"coat": "外袍",
"coil": "盤繞",
"collar": "項圈",
"colon": ":",
"compellingorb": "強制球體",
"cord": "捆綁",
"cost": "花費:",
@ -6541,6 +6883,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"cudgel": "木棒",
"d33": "吉得賓誘餌",
"dart": "鏢",
"dash": "-",
"dgr": "匕首",
"dhn": "暗黑破壞神之角",
"dir": "長匕首",
@ -6730,6 +7073,89 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"mbl": "扣帶",
"mbr": "曼菲斯托之腦",
"mbt": "鎖鍊靴",
"merc01": "阿里撒",
"merc02": "安普利沙",
"merc03": "安諾",
"merc04": "艾伯海亞",
"merc05": "埃利",
"merc06": "派吉",
"merc07": "巴森提",
"merc08": "布萊謝",
"merc09": "京子",
"merc10": "克勞蒂亞",
"merc11": "肯吉",
"merc12": "凱爾",
"merc13": "維沙利亞",
"merc14": "依麗莎",
"merc15": "弗蘿利亞",
"merc16": "菲歐娜",
"merc17": "基溫尼",
"merc18": "蓋爾",
"merc19": "哈娜",
"merc20": "海瑟",
"merc21": "艾蓮娜",
"merc22": "迪亞娜",
"merc23": "依索迪",
"merc24": "迪佛",
"merc25": "依索拉",
"merc26": "衣東亞",
"merc27": "萊茵",
"merc28": "梅可",
"merc29": "馬哈拉",
"merc30": "萊沙",
"merc31": "梅葛哈",
"merc32": "奧蓮娜",
"merc33": "奧麗亞娜",
"merc34": "萊恩",
"merc35": "羅珊妮",
"merc36": "萊紗",
"merc37": "莎倫",
"merc38": "席克哈",
"merc39": "戴比",
"merc40": "泰蓮娜",
"merc41": "溫蒂",
"merca201": "哈撒迪",
"merca202": "艾利希爾",
"merca203": "阿撒爾",
"merca204": "亞斯哈",
"merca205": "克哈蘭",
"merca206": "哈辛",
"merca207": "羅森",
"merca208": "艾米羅",
"merca209": "培撒姆",
"merca210": "費索",
"merca211": "傑馬利",
"merca212": "卡辛姆",
"merca213": "吉爾薩",
"merca214": "米山",
"merca215": "雷哈拉",
"merca216": "迪爾卡",
"merca217": "尼爾亞",
"merca218": "衣利森",
"merca219": "撒那利",
"merca220": "瓦黑德",
"merca221": "維克懷特",
"merca222": "喬安妮",
"merca223": "巴拉尼",
"merca224": "賈巴利",
"merca225": "迪法",
"merca226": "拉爾丁",
"merca227": "鐵拉西",
"merca228": "艾傑希德",
"merca229": "拿費特",
"merca230": "凱立爾",
"merca231": "費特",
"merca232": "杰希夫",
"merca233": "凡吉",
"merca234": "海費特",
"merca235": "撒達",
"merca236": "亞提賈",
"merca237": "拉黑德",
"merca238": "亞席德",
"merca239": "賈魯夫",
"merca240": "弗勒克斯",
"merca241": "斯高屈",
"merclevelup": "我現在覺得強壯多了",
"meshif": "馬席夫",
"mgl": "鎖鍊手套",
"minion": "嘍囉",
@ -7177,6 +7603,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"sml": "小盾牌",
"sol": "灵魂之",
"song": "歌",
"space": " ",
"spawn": "重生的",
"spc": "狼牙棒",
"spe": "一股邪恶力量",
@ -7254,6 +7681,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"strchrstm": "耐力",
"strchrstr": "力量",
"strchrvit": "體力",
"strhirespecial1": "火箭",
"strhirespecial10": "快速施展火焰",
"strhirespecial2": "冰箭",
"strhirespecial3": "戳刺攻擊",
"strhirespecial4": "抗毒",
"strhirespecial5": "閃電",
"strhirespecial6": "冷",
"strhirespecial7": "火",
"strhirespecial8": "快速施展閃電",
"strhirespecial9": "快速施展冰冷",
"stu": "鑲嵌甲",
"stun attack": "昏眩攻擊",
"suit": "衣",

View File

@ -293595,6 +293595,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"vendorTabs": "/ui/vendor-tabs.png",
"boxPieces": "/ui/boxpieces.png",
"focus16": "/ui/focus16.png",
"textslid": "/ui/textslid.png",
"popbelt": "/ui/popbelt.png",
"minipanel": "/ui/minipanel.png",
"minipanelBtns": "/ui/minipanel-btns.png",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,191 @@
import { describe, it, expect, vi } from 'vitest'
import {
WorldPanelsHud,
HIRE_DIALOG_WIDTH,
HIRE_DIALOG_HEIGHT,
HIRE_CONFIRM_WIDTH,
HIRE_CONFIRM_HEIGHT,
} from '../../src/client/ui/world-panels.ts'
import { resolveHirelingCandidates } from '../../src/common/units/hireling-table.ts'
function createMockCtx() {
const drawnTexts: string[] = []
let fillRectCount = 0
let strokeRectCount = 0
return {
drawnTexts,
get fillRectCount() {
return fillRectCount
},
get strokeRectCount() {
return strokeRectCount
},
fillStyle: '',
strokeStyle: '',
fillRect: vi.fn(() => fillRectCount++),
strokeRect: vi.fn(() => strokeRectCount++),
fillText: vi.fn((text: string) => drawnTexts.push(text)),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
} as unknown as CanvasRenderingContext2D & { drawnTexts: string[]; fillRectCount: number; strokeRectCount: number }
}
describe('WorldPanelsHud — Mercenary Hire Stone Dialog (0x6faf9440 & 0x6faf7500 parity)', () => {
it('identifies dialog hitboxes accurately per D2Client.dll 0x6faf9440', () => {
const wp = new WorldPanelsHud()
wp.activePanel = 'hire'
// Centered at 155, 105 (490x350)
expect(wp.isPointInHireMercDialog(100, 50)).toBe(false)
expect(wp.isPointInHireMercDialog(155, 105)).toBe(true)
expect(wp.isPointInHireMercDialog(400, 300)).toBe(true)
expect(wp.isPointInHireMercDialog(644, 454)).toBe(true)
expect(wp.isPointInHireMercDialog(650, 460)).toBe(false)
// Confirmation dialog bounds (250, 190, 300, 160)
wp.replaceMercConfirmOpen = true
expect(wp.isPointInHireMercDialog(160, 110)).toBe(false)
expect(wp.isPointInHireMercDialog(250, 190)).toBe(true)
expect(wp.isPointInHireMercDialog(400, 250)).toBe(true)
expect(wp.isPointInHireMercDialog(550, 350)).toBe(true)
})
it('draws authentic 490x350 stone dialog with ItemDesc1s gold header and candidate list', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.playerGold = 5000
wp.stashGold = 12000
const rawCandidates = [
{ nameId: 3411, seed: 0x1234 },
{ nameId: 3412, seed: 0x5678 },
]
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 10, 0, 0, true, 'ENG')
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.fillRectCount).toBeGreaterThan(0)
// Title with total gold 17000
expect(ctx.drawnTexts.some((t) => t.includes('17000') && t.includes('Hire which Mercenary?'))).toBe(true)
// Candidates rendered with Lvl, Life, Def, Cost
expect(ctx.drawnTexts.some((t) => t.includes('Aliza - Lvl: 8'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('Fire Arrow'))).toBe(true)
// Bottom cancel button
expect(ctx.drawnTexts.some((t) => t.includes('cancel'))).toBe(true)
})
it('draws ItemDesc1t when hire list is empty', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.mercHireList = []
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.drawnTexts.some((t) => t.includes('There are no Mercenaries left to hire.'))).toBe(true)
})
it('handles candidate hover, scrolling, and direct hire when player has no existing mercenary', () => {
const wp = new WorldPanelsHud()
wp.activePanel = 'hire'
wp.activeNpcId = 150
const rawCandidates = Array.from({ length: 10 }, (_, i) => ({
nameId: 3411 + i,
seed: 0x1000 + i * 0x100,
}))
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
// 1. Wheel scroll
expect(wp.handleHireWheel(1)).toBe(true)
expect(wp.hireScrollOffset).toBe(1)
expect(wp.handleHireWheel(-1)).toBe(true)
expect(wp.hireScrollOffset).toBe(0)
// 2. Mouse move hover
wp.handleHireMercMove(200, 160) // over Candidate 0
expect(wp.hireHoveredIndex).toBe(0)
// 3. Direct click to hire when player has no existing mercenary
let hiredNpcId = 0
let hiredMercId = 0
const onHireMerc = vi.fn((npcId: number, mercId: number) => {
hiredNpcId = npcId
hiredMercId = mercId
})
const handled = wp.handleHireMercClick(200, 160, { onHireMerc })
expect(handled).toBe(true)
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
expect(wp.activePanel).toBe('none')
})
it('prompts confirmation dialog when player already possesses a mercenary (0x6faf7500)', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.activeNpcId = 150
wp.merc = { mercId: 42, isDead: false } // Existing active mercenary
const rawCandidates = [{ nameId: 3411, seed: 0x1234 }]
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
const onHireMerc = vi.fn()
// First click: selects candidate
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.hireSelectedIndex).toBe(0)
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(onHireMerc).not.toHaveBeenCalled()
// Second click on selected candidate: triggers replacement warning dialog
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(true)
expect(wp.pendingHireCandidate?.nameId).toBe(3411)
// Render with confirm dialog open
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.drawnTexts.some((t) => t.includes('This Mercenary will replace your current one.'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('Yes'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('No'))).toBe(true)
// Click "No" (cx + 150 = 400, cy + 128 = 318)
wp.handleHireMercClick(400, 318, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(wp.activePanel).toBe('hire')
expect(onHireMerc).not.toHaveBeenCalled()
// Open again and click "Yes" (cx + 150 = 400, cy + 104 = 294)
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(true)
wp.handleHireMercClick(400, 294, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
expect(wp.activePanel).toBe('none')
})
it('clicking cancel button closes hire dialog and re-opens NPC menu (0x6faf9090)', () => {
const wp = new WorldPanelsHud()
wp.openNpcMenuByName('Kashya', 400, 220)
expect(wp.npcMenu).not.toBeNull()
wp.clickNpcMenuOption('hire')
expect(wp.activePanel).toBe('hire')
expect(wp.npcMenu).toBeNull()
// Click Cancel button at dialogX + 245 = 400, dialogY + 315 = 420
const onClose = vi.fn()
wp.handleHireMercClick(400, 420, { onClose })
expect(wp.activePanel).toBe('none')
expect(onClose).toHaveBeenCalled()
expect(wp.npcMenu).not.toBeNull()
expect(wp.npcMenu?.npcName).toContain('Kashya')
})
})

View File

@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest'
import {
HIRELING_RECORDS,
resolveHirelingAct,
resolveHirelingName,
resolveHirelingDesc,
resolveHirelingCandidate,
resolveHirelingCandidates,
formatHirelingCandidateLines,
} from '../../src/common/units/hireling-table.ts'
describe('hireling-table (authoritative Diablo II v1.13c Hireling.txt / D2Common parity)', () => {
it('loads all 120 compiled hireling records with valid attributes', () => {
expect(HIRELING_RECORDS).toHaveLength(120)
for (const r of HIRELING_RECORDS) {
expect([0, 100]).toContain(r.version)
expect([271, 338, 359, 561]).toContain(r.classId)
expect([1, 2, 3, 5]).toContain(r.act)
expect([1, 2, 3]).toContain(r.difficulty)
expect([150, 198, 252, 515]).toContain(r.seller)
expect(r.level).toBeGreaterThan(0)
expect(r.hireCost).toBeGreaterThan(0)
expect(r.hp).toBeGreaterThan(0)
expect(r.skills.length).toBeGreaterThan(0)
}
})
it('resolves act index from mercenary name ID matching D2Common_10988 (0x6fd7c9e0)', () => {
// Act 1 Rogue (merc01..merc34 / global 3411..3444) -> act 0
expect(resolveHirelingAct(3411, true)).toBe(0)
expect(resolveHirelingAct(3444, true)).toBe(0)
// Act 2 Desert Mercenary (merca201..merca221 / global 1019..1039) -> act 1
expect(resolveHirelingAct(1019, true)).toBe(1)
expect(resolveHirelingAct(1039, true)).toBe(1)
// Act 3 Iron Wolf (merca222..merca241 / global 1040..1059) -> act 2
expect(resolveHirelingAct(1040, true)).toBe(2)
expect(resolveHirelingAct(1059, true)).toBe(2)
expect(resolveHirelingAct(11021, true)).toBe(2) // Jarulf
expect(resolveHirelingAct(0x0421, true)).toBe(2) // 1057 Jarulf legacy
// Act 5 Barbarian (MercX101..MercX167 / global 10835..10901) -> act 4
expect(resolveHirelingAct(10835, true)).toBe(4)
expect(resolveHirelingAct(10901, true)).toBe(4)
})
it('resolves localized mercenary names in English and Chinese per D2Client.dll 0x6faf9783', () => {
// Act 1
expect(resolveHirelingName(3411, 'ENG')).toBe('Aliza')
expect(resolveHirelingName(3411, 'CHI')).toBe('阿里撒')
expect(resolveHirelingName(3444, 'ENG')).toBe('Ryann')
expect(resolveHirelingName(3444, 'CHI')).toBe('萊恩')
// Act 2
expect(resolveHirelingName(1019, 'ENG')).toBe('Hazade')
expect(resolveHirelingName(1019, 'CHI')).toBe('哈撒迪')
expect(resolveHirelingName(1039, 'ENG')).toBe('Vikhyat')
expect(resolveHirelingName(1039, 'CHI')).toBe('維克懷特')
// Act 3
expect(resolveHirelingName(1040, 'ENG')).toBe('Jelani')
expect(resolveHirelingName(1040, 'CHI')).toBe('喬安妮')
expect(resolveHirelingName(11021, 'ENG')).toBe('Jarulf')
expect(resolveHirelingName(0x0421, 'ENG')).toBe('Jarulf')
// Act 5
expect(resolveHirelingName(10835, 'ENG')).toBe('Varaya')
expect(resolveHirelingName(10835, 'CHI')).toBe('伐德哈卡')
expect(resolveHirelingName(10901, 'ENG')).toBe('Theodoric')
expect(resolveHirelingName(10901, 'CHI')).toBe('狄奧多理')
})
it('resolves ability descriptions matching D2Common_10091 (0x6fd7c9c0) in ENG and CHI', () => {
expect(resolveHirelingDesc(1, 'ENG')).toBe('Fire Arrow')
expect(resolveHirelingDesc(1, 'CHI')).toBe('火箭')
expect(resolveHirelingDesc(2, 'ENG')).toBe('Cold Arrow')
expect(resolveHirelingDesc(2, 'CHI')).toBe('冰箭')
expect(resolveHirelingDesc(3, 'ENG')).toBe('Combat')
expect(resolveHirelingDesc(3, 'CHI')).toBe('戰鬥')
expect(resolveHirelingDesc(4, 'ENG')).toBe('Defensive')
expect(resolveHirelingDesc(4, 'CHI')).toBe('防禦')
expect(resolveHirelingDesc(5, 'ENG')).toBe('Offensive')
expect(resolveHirelingDesc(5, 'CHI')).toBe('進攻')
expect(resolveHirelingDesc(6, 'ENG')).toBe('Fire')
expect(resolveHirelingDesc(6, 'CHI')).toBe('火')
expect(resolveHirelingDesc(7, 'ENG')).toBe('Cold')
expect(resolveHirelingDesc(7, 'CHI')).toBe('冷')
expect(resolveHirelingDesc(8, 'ENG')).toBe('Lightning')
expect(resolveHirelingDesc(8, 'CHI')).toBe('閃電')
expect(resolveHirelingDesc(0, 'ENG')).toBe('')
expect(resolveHirelingDesc(0, 'CHI')).toBe('')
})
it('formats candidate lines per D2Client.dll 0x6faf9440', () => {
const eng = formatHirelingCandidateLines('Aliza', 8, 90, 55, 175, 'Fire Arrow', 'ENG')
expect(eng.line1).toBe('Aliza - Lvl: 8 Life: 90 Def: 55 Cost: 175')
expect(eng.line2).toBe(' Fire Arrow')
const chi = formatHirelingCandidateLines('阿里撒', 8, 90, 55, 175, '火箭', 'CHI')
expect(chi.line1).toBe('阿里撒 - 等級: 8 生命: 90 防禦: 55 花費: 175')
expect(chi.line2).toBe(' 火箭')
const blankSpecial = formatHirelingCandidateLines('Varaya', 30, 320, 200, 9200, '', 'ENG')
expect(blankSpecial.line2).toBe(' ')
})
it('computes exact 64-bit LCG stats matching D2Common_10458 across difficulties', () => {
// Act 1 Rogue (Normal, seed 0x1234, playerLevel 10)
const c1 = resolveHirelingCandidate({ nameId: 3411, seed: 0x1234 }, 10, 0, 0, true, 'ENG')
expect(c1.name).toBe('Aliza')
expect(c1.actIndex).toBe(0)
expect(c1.classId).toBe(271)
expect(c1.level).toBe(8)
expect(c1.hp).toBe(90)
expect(c1.defense).toBe(55)
expect(c1.strength).toBe(41)
expect(c1.dexterity).toBe(55)
expect(c1.cost).toBe(175)
expect(c1.hireDesc).toBe('Fire Arrow')
// Act 2 Desert Mercenary (Nightmare, seed 0x5678, playerLevel 50)
const c2 = resolveHirelingCandidate({ nameId: 1019, seed: 0x5678 }, 50, 1, 1, true, 'ENG')
expect(c2.name).toBe('Hazade')
expect(c2.actIndex).toBe(1)
expect(c2.classId).toBe(338)
expect(c2.level).toBeGreaterThanOrEqual(45)
expect(c2.cost).toBeGreaterThan(5000)
expect(['Combat', 'Defensive', 'Offensive']).toContain(c2.hireDesc)
// Act 5 Barbarian (Normal, seed 0x9abc, playerLevel 35)
const c5 = resolveHirelingCandidate({ nameId: 10835, seed: 0x9abc }, 35, 0, 4, true, 'ENG')
expect(c5.name).toBe('Varaya')
expect(c5.actIndex).toBe(4)
expect(c5.classId).toBe(561)
expect(c5.level).toBeGreaterThanOrEqual(30)
expect(c5.cost).toBeGreaterThanOrEqual(9000)
expect(c5.hireDesc).toBe('')
})
it('resolveHirelingCandidates maps candidate list cleanly', () => {
const raw = [
{ nameId: 3411, seed: 0x1111 },
{ nameId: 3412, seed: 0x2222 },
{ nameId: 3413, seed: 0x3333 },
]
const res = resolveHirelingCandidates(raw, 12, 0, 0, true, 'CHI')
expect(res).toHaveLength(3)
expect(res[0]!.nameId).toBe(3411)
expect(res[1]!.nameId).toBe(3412)
expect(res[2]!.nameId).toBe(3413)
expect(res[0]!.line1).toContain('等級:')
})
})