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)

This commit is contained in:
troytt 2026-10-03 01:50:22 +00:00
parent 2733a720ae
commit 171f7c00fc
9 changed files with 1636 additions and 792 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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.',
],

View File

@ -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()
})
})
})

View File

@ -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)
}
})

View File

@ -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

View File

@ -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> = {}): 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<string, unknown>
expect(hud['canTransmuteCube']).toBeUndefined()
expect(hud['resolveGambleOutcome']).toBeUndefined()
expect(hud['getRepairCost']).toBeUndefined()
})
})
// ===========================================================================
// Issue #673: Non-Canonical "Entering: <Area>" 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/)
})
})
})

View File

@ -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)')

View File

@ -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

View File

@ -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)