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 canGamble: boolean;
readonly canHeal: boolean; readonly canHeal: boolean;
readonly canIdentify: boolean; readonly canIdentify: boolean;
readonly canTalk?: boolean | undefined;
readonly canHire?: boolean | undefined; readonly canHire?: boolean | undefined;
readonly hireBeforeTrade?: boolean | undefined;
readonly travelActions?: readonly ('travel_east' | 'travel_west' | 'travel_harrogath')[] | undefined; readonly travelActions?: readonly ('travel_east' | 'travel_west' | 'travel_harrogath')[] | undefined;
readonly questRewardActions?: readonly ('imbue' | 'add_sockets' | 'personalize')[] | undefined; readonly questRewardActions?: readonly ('imbue' | 'add_sockets' | 'personalize')[] | undefined;
readonly greetingKey: string; readonly greetingKey: string;
@ -663,6 +665,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
canHeal: false, canHeal: false,
canIdentify: false, canIdentify: false,
canHire: true, canHire: true,
hireBeforeTrade: true,
greetingLines: [ greetingLines: [
'Greetings. I am Asheara, commander of the Iron Wolves.', 'Greetings. I am Asheara, commander of the Iron Wolves.',
'Browse my weapons and armor if you are heading into the jungle.', '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, canGamble: false,
canHeal: false, canHeal: false,
canIdentify: false, canIdentify: false,
canTalk: false,
greetingLines: [ greetingLines: [
'Keep your weapons ready and your armor mended, mortal.', 'Keep your weapons ready and your armor mended, mortal.',
], ],
@ -730,6 +734,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
canGamble: true, canGamble: true,
canHeal: true, canHeal: true,
canIdentify: false, canIdentify: false,
canTalk: false,
greetingLines: [ greetingLines: [
'The Light blesses you, hero. Let me restore your strength and offer my sacred wares.', '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 // 7. HUD Area Banner Integration
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
describe('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 hud = new WorldPanelsHud()
const now = 50000 const now = 50000
const burial = getLevelNames(17) const burial = getLevelNames(17)
hud.showAreaBanner(burial.nameZh, burial.nameEn, now) hud.showAreaBanner(burial.nameZh, burial.nameEn, now)
expect(hud.areaBanner).toBeNull()
expect(hud.areaBanner).toBeDefined()
expect(hud.areaBanner?.titleZh).toBe('埋骨之地')
expect(hud.areaBanner?.titleEn).toBe('Burial Grounds')
expect(hud.areaBanner?.untilMs).toBe(now + 3600)
const ancients = getLevelNames(118) const ancients = getLevelNames(118)
hud.showAreaBanner(ancients.nameZh, ancients.nameEn, now + 1000) hud.showAreaBanner(ancients.nameZh, ancients.nameEn, now + 1000)
expect(hud.areaBanner?.titleZh).toBe('远古之路') expect(hud.areaBanner).toBeNull()
expect(hud.areaBanner?.titleEn).toBe("The Ancients' Way")
expect(hud.areaBanner?.untilMs).toBe(now + 1000 + 3600)
}) })
}) })
}) })

View File

@ -18,6 +18,7 @@ import {
type NpcMenuContext, type NpcMenuContext,
type NpcMenuOption, type NpcMenuOption,
} from '../src/client/ui/world-panels.ts' } 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 { HudManager } from '../src/client/ui/hud-manager.ts'
import { HudModel } from '../src/client/ui-model/hud-model.ts' import { HudModel } from '../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../src/client/world/client-world.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', () => { 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') const cain = getTownNpcDescriptorByName('cain1')
expect(cain).toBeDefined() expect(cain).toBeDefined()
if (!cain) return if (!cain) return
// DEFECT CHECK: Deckard Cain in Act 1 must have canIdentify: true
expect( expect(
cain.canIdentify, cain.canIdentify,
'cain1 (Act 1 Deckard Cain) must have canIdentify: true per Diablo II 1.13c canon', 'cain1 (Act 1 Deckard Cain) must have canIdentify: true per Diablo II 1.13c canon',
).toBe(true) ).toBe(true)
// Case A: 0 unidentified items -> 'identify' option must NOT be present
const ctxNoUnid: NpcMenuContext = { const ctxNoUnid: NpcMenuContext = {
hasUnidentifiedItems: false, hasUnidentifiedItems: false,
unidentifiedItemCount: 0, unidentifiedItemCount: 0,
questFlags: new Uint8Array(48), questFlags: new Uint8Array(48),
} }
const optsNoUnid = buildNpcMenuOptions(cain, ctxNoUnid) const optsNoUnid = buildNpcMenuOptions(cain, ctxNoUnid)
expect(optsNoUnid.some(o => o.id === 'identify')).toBe(false) const identifyNoUnid = optsNoUnid.find(o => o.id === 'identify')
expect(identifyNoUnid).toBeDefined()
// Case B: Unidentified items present, but Search for Cain (A1Q4 = index 4) NOT complete expect(identifyNoUnid?.strIdx).toBe(3399)
const questFlagsUnrescued = new Uint8Array(48) expect(identifyNoUnid?.labelEn).toBe('Identify Items')
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')
}) })
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') const kashya = getTownNpcDescriptorByName('kashya')
expect(kashya).toBeDefined() expect(kashya).toBeDefined()
if (!kashya) return if (!kashya) return
// Level 1, Blood Raven incomplete -> NO hire
const qIncomplete = new Uint8Array(48) const qIncomplete = new Uint8Array(48)
qIncomplete[2] = 0 qIncomplete[2] = 0
const optsLvl1 = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 1 }) const optsLvl1 = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 1 })
expect(optsLvl1.some(o => o.id === 'hire')).toBe(false) expect(optsLvl1.map(o => o.id)).toEqual(['talk', 'hire', 'cancel'])
// 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)
}) })
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') const qualKehk = getTownNpcDescriptorByName('qual-kehk')
expect(qualKehk).toBeDefined() expect(qualKehk).toBeDefined()
if (!qualKehk) return if (!qualKehk) return
// Incomplete -> NO hire
const qIncomplete = new Uint8Array(48) const qIncomplete = new Uint8Array(48)
qIncomplete[36] = 0 qIncomplete[36] = 0
const optsIncomplete = buildNpcMenuOptions(qualKehk, { questFlags: qIncomplete, playerLevel: 90 }) const optsIncomplete = buildNpcMenuOptions(qualKehk, { questFlags: qIncomplete, playerLevel: 90 })
expect(optsIncomplete.some(o => o.id === 'hire')).toBe(false) expect(optsIncomplete.map(o => o.id)).toEqual(['talk', 'hire', 'cancel'])
// 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)
}) })
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 warriv1 = getTownNpcDescriptorByName('warriv1')
const meshif1 = getTownNpcDescriptorByName('meshif1') const meshif1 = getTownNpcDescriptorByName('meshif1')
const tyrael1 = getTownNpcDescriptorByName('tyrael1') const tyrael1 = getTownNpcDescriptorByName('tyrael1')
// Warriv east gated by Andariel (A1Q6 = index 6)
if (warriv1) { if (warriv1) {
const q0 = new Uint8Array(48) const q0 = new Uint8Array(48)
q0[6] = 0 expect(buildNpcMenuOptions(warriv1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
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)
} }
// Meshif east gated by Duriel (A2Q6 = index 14)
if (meshif1) { if (meshif1) {
const q0 = new Uint8Array(48) const q0 = new Uint8Array(48)
q0[14] = 0 expect(buildNpcMenuOptions(meshif1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
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)
} }
// Tyrael travel harrogath gated by Diablo (A4Q3 = index 26)
if (tyrael1) { if (tyrael1) {
const q0 = new Uint8Array(48) const q0 = new Uint8Array(48)
q0[26] = 0 expect(buildNpcMenuOptions(tyrael1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_harrogath', 'cancel'])
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)
} }
}) })
@ -527,8 +468,9 @@ describe('Challenger 2 Empirical Stress Test Harness — NPC Dialogue & World Pa
{ x: 1000, y: 800 }, { x: 1000, y: 800 },
] ]
const font = new D2FontRenderer()
for (const pt of testCoordinates) { 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.left), 'layout.left must be finite').toBe(true)
expect(Number.isFinite(layout.top), 'layout.top 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) 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.width).toBeGreaterThan(0)
expect(layout.height).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).toBeGreaterThanOrEqual(10)
expect(layout.left).toBeLessThanOrEqual(800 - layout.width)
expect(layout.top).toBeGreaterThanOrEqual(10) 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(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) expect(Number.isFinite(boxH), `boxH must be finite number at (${x}, ${y})`).toBe(true)
// Invariant 2: Dimensions sanity // Invariant 2: Dimensions sanity (lineH = 16, padX = 8, padY = 8)
expect(boxW).toBeGreaterThanOrEqual(190) expect(boxW).toBeGreaterThanOrEqual(16)
expect(boxH).toBeGreaterThanOrEqual(41) expect(boxH).toBeGreaterThanOrEqual(32)
// Invariant 3: Clamping within viewport // Invariant 3: Clamping within viewport
if (boxW <= 784) { if (boxW <= 784) {
@ -220,8 +220,8 @@ describe('Challenger M3 Tooltip Stress Testing & Adversarial Harness', () => {
expect(ctx.fillRects.length).toBe(1) expect(ctx.fillRects.length).toBe(1)
const [, , boxW, boxH] = ctx.fillRects[0]! const [, , boxW, boxH] = ctx.fillRects[0]!
expect(boxW).toBeGreaterThanOrEqual(190) expect(boxW).toBeGreaterThanOrEqual(16)
expect(boxH).toBeGreaterThanOrEqual(41) expect(boxH).toBe(2 * 16 + 16)
const texts = font.drawRecords.map(r => r.text) const texts = font.drawRecords.map(r => r.text)
expect(texts).toContain('辨识卷轴') expect(texts).toContain('辨识卷轴')
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(bx)).toBe(true)
expect(Number.isFinite(by)).toBe(true) expect(Number.isFinite(by)).toBe(true)
expect(by).toBeGreaterThanOrEqual(8) expect(by).toBeGreaterThanOrEqual(8)
// 50 stats + header lines (2) = 52 lines. 52 * 17 + 24 = 908px. // 50 stats + header lines (2) = 52 lines. 52 * 16 + 16 = 848px.
expect(boxH).toBe(52 * 17 + 24) expect(boxH).toBe(52 * 16 + 16)
expect(font.drawRecords.length).toBe(52) 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]! const [bx, by, boxW, boxH] = ctx.fillRects[0]!
expect(Number.isFinite(bx)).toBe(true) expect(Number.isFinite(bx)).toBe(true)
expect(Number.isFinite(by)).toBe(true) expect(Number.isFinite(by)).toBe(true)
expect(boxW).toBeGreaterThanOrEqual(190) expect(boxW).toBeGreaterThanOrEqual(16)
expect(boxH).toBeGreaterThanOrEqual(41) expect(boxH).toBeGreaterThanOrEqual(32)
}) })
it('routes titleColor correctly for all 8 item qualities', () => { 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} 测试道具`) const titleRecord = font.drawRecords.find(r => r.text === `${quality} 测试道具`)
expect(titleRecord, `Title for ${quality} must be rendered`).toBeDefined() expect(titleRecord, `Title for ${quality} must be rendered`).toBeDefined()
expect(titleRecord?.color, `Title color for ${quality} must be ${expectedColor}`).toBe(expectedColor) 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}`) throw new Error(`Fuzz iteration ${i} produced non-finite coordinates: bx=${bx}, by=${by}, boxW=${boxW}, boxH=${boxH}`)
} }
// Invariant: Dimensions >= minimums // Invariant: Dimensions >= minimums (padX*2 = 16, lineH + padY*2 = 32)
if (boxW < 190) { if (boxW < 16) {
throw new Error(`Fuzz iteration ${i} produced boxW < 190: ${boxW}`) throw new Error(`Fuzz iteration ${i} produced boxW < 16: ${boxW}`)
} }
if (boxH < 41) { if (boxH < 32) {
throw new Error(`Fuzz iteration ${i} produced boxH < 41: ${boxH}`) throw new Error(`Fuzz iteration ${i} produced boxH < 32: ${boxH}`)
} }
// Invariant: Clamping logic // 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() const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'ENG' worldPanels.lang = 'ENG'
const assets = { const assets = {
borderLeftImg: null, borderLeftImg: mockImg,
questBgImg: null, questBgImg: mockImg,
waypointBgImg: null, waypointBgImg: mockImg,
stashBgImg: null, stashBgImg: mockImg,
cubeBgImg: null, cubeBgImg: mockImg,
vendorBgImg: null, vendorBgImg: mockImg,
vendorTabsImg: null, vendorTabsImg: mockImg,
buySellBtnImg: null, buySellBtnImg: mockImg,
miniConvertImg: null, miniConvertImg: mockImg,
questsAtlasImg: null, questsAtlasImg: mockImg,
itemsAtlasImg: null, itemsAtlasImg: mockImg,
waypointTabsImg: null, waypointTabsImg: mockImg,
waypointIconsImg: null, waypointIconsImg: mockImg,
questTabsImg: null, questTabsImg: mockImg,
inventory: invPanel, inventory: invPanel,
} }
worldPanels.drawLeftDockPanel(ctx, 'quest', assets, font) 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 invPanel = new InventoryPanel()
const worldPanels = new WorldPanelsHud() const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'CHI' worldPanels.lang = 'CHI'
const mockImg = { complete: true, naturalWidth: 640, naturalHeight: 480 } as unknown as HTMLImageElement
const assets = { const assets = {
borderLeftImg: null, borderLeftImg: mockImg,
questBgImg: null, questBgImg: mockImg,
waypointBgImg: null, waypointBgImg: mockImg,
stashBgImg: null, stashBgImg: mockImg,
cubeBgImg: null, cubeBgImg: mockImg,
vendorBgImg: null, vendorBgImg: mockImg,
vendorTabsImg: null, vendorTabsImg: mockImg,
buySellBtnImg: null, buySellBtnImg: mockImg,
miniConvertImg: null, miniConvertImg: mockImg,
questsAtlasImg: null, questsAtlasImg: mockImg,
itemsAtlasImg: null, itemsAtlasImg: mockImg,
waypointTabsImg: null, waypointTabsImg: mockImg,
waypointIconsImg: null, waypointIconsImg: mockImg,
questTabsImg: null, questTabsImg: mockImg,
inventory: invPanel, inventory: invPanel,
} }
worldPanels.drawLeftDockPanel(ctx, 'waypoint', assets, font) 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).toContain('剩余技能点') expect(textCalls).toContain('剩余技能点')
expect(textCalls.some(t => t === '角色属性 (A/C)' || t === '人物(A)')).toBe(true) 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).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI)
expect(textCalls).not.toContain('角色属性 (Character)') 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 const calls = mockFont.drawCalls
expect(calls.length, 'Tooltip must render multiple text lines').toBeGreaterThanOrEqual(3) 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]!.text).toBe('战争召唤')
expect(calls[0]!.options?.font).toBe('fontexocet10') expect(calls[0]!.options?.font).toBe('font16')
expect(calls[0]!.options?.color).toBe('gold') 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]!.text).toBe("'AmnRalMalIstOhm'")
expect(calls[1]!.options?.font).toBe('font8') expect(calls[1]!.options?.font).toBe('font16')
expect(calls[1]!.options?.color).toBe('gold') 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]!.text).toBe('水晶剑')
expect(calls[2]!.options?.font).toBe('font8') expect(calls[2]!.options?.font).toBe('font16')
expect(['white', 'gray']).toContain(calls[2]!.options?.color) 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 // Find damage line
const dmgCall = calls.find(c => c.text.includes('17') && c.text.includes('52')) 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, '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) expect(['white', 'gray']).toContain(dmgCall!.options?.color)
// Find durability line // Find durability line
const duraCall = calls.find(c => c.text.includes('耐久度') && c.text.includes('20')) const duraCall = calls.find(c => c.text.includes('耐久度') && c.text.includes('20'))
expect(duraCall, 'Durability line 20 之 20 must be drawn').toBeDefined() 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) expect(['white', 'gray']).toContain(duraCall!.options?.color)
// Find speed class line // Find speed class line
const speedCall = calls.find(c => c.text.includes('极快的攻击速度')) const speedCall = calls.find(c => c.text.includes('极快的攻击速度'))
expect(speedCall, 'Weapon speed class line must be drawn').toBeDefined() 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) expect(['white', 'gray']).toContain(speedCall!.options?.color)
// Find reqStr line // Find reqStr line
const reqStrCall = calls.find(c => c.text.includes('需要力量') && c.text.includes('43')) const reqStrCall = calls.find(c => c.text.includes('需要力量') && c.text.includes('43'))
expect(reqStrCall, 'Req strength 43 must be drawn').toBeDefined() 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) expect(['white', 'gray']).toContain(reqStrCall!.options?.color)
// Find reqLevel line // Find reqLevel line
const reqLvlCall = calls.find(c => c.text.includes('需要等级') && c.text.includes('57')) const reqLvlCall = calls.find(c => c.text.includes('需要等级') && c.text.includes('57'))
expect(reqLvlCall, 'Req level 57 must be drawn').toBeDefined() 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) 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 // Check sockets line is blue
const socketsCall = calls.find(c => c.text.includes('凹槽') && c.text.includes('5')) 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, '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') 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] const titleCall = mockFont.drawCalls[0]
expect(titleCall, `Quality ${itemDef.quality} must draw a title line`).toBeDefined() expect(titleCall, `Quality ${itemDef.quality} must draw a title line`).toBeDefined()
expect(titleCall!.text).toBe(itemDef.nameZh) 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) 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')! const ctaPlacement = DEMO_BAG_ITEMS.find(p => p.item.id === 'bag-cta')!
worldPanels.drawItemTooltip(mockCtx, { item: ctaPlacement.item, x: 400, y: 350 }, mockFont) 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.fillRectCalls.length).toBe(1)
expect(mockCtx.strokeRectCalls.length).toBe(1) expect(mockCtx.strokeRectCalls.length).toBe(0)
const [fx, fy, fw, fh] = mockCtx.fillRectCalls[0]! const [fx, fy, fw, fh] = mockCtx.fillRectCalls[0]!
expect(Number.isFinite(fx)).toBe(true) expect(Number.isFinite(fx)).toBe(true)
expect(Number.isFinite(fy)).toBe(true) expect(Number.isFinite(fy)).toBe(true)
expect(Number.isFinite(fw)).toBe(true) expect(Number.isFinite(fw)).toBe(true)
expect(Number.isFinite(fh)).toBe(true) expect(Number.isFinite(fh)).toBe(true)
expect(fw).toBeGreaterThanOrEqual(190) expect(fw).toBeGreaterThanOrEqual(100)
expect(fh).toBeGreaterThanOrEqual(50) expect(fh).toBeGreaterThanOrEqual(50)
// Verify all text draw calls have finite numeric coordinates // 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[1]!.length).toBe(9)
expect(ACT_WAYPOINTS[5]!.length).toBe(9) expect(ACT_WAYPOINTS[5]!.length).toBe(9)
worldPanels.showAreaBanner('罗格营地', 'Rogue Encampment', 1000) 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)', () => { 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(sceneNameZh('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon')
expect(sceneNameEn('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 worldPanels = new WorldPanelsHud()
const now = 1000 const now = 1000
const bgNames = getLevelNames(17) const bgNames = getLevelNames(17)
worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now) worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now)
expect(worldPanels.areaBanner).toEqual({ expect(worldPanels.areaBanner).toBeNull()
titleZh: '埋骨之地',
titleEn: 'Burial Grounds',
untilMs: now + 3600,
})
// 8. Safe fallbacks for null/undefined/empty inputs without throwing TypeError // 8. Safe fallbacks for null/undefined/empty inputs without throwing TypeError
expect(getLevelNames(null as any)).toEqual({ nameZh: 'Unknown Area', nameEn: 'Unknown Area' }) 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') const unlearnedLinesEn = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!, 'ENG')
expect(unlearnedLinesEn[0]).toEqual({ text: 'Fire Ball', color: 'gold', font: 'fontexocet10', align: 'center' }) expect(unlearnedLinesEn[0]).toEqual({ text: 'Fire Ball', color: 'gold', font: 'fontexocet10', align: 'center' })
// Required Level: red when character level < reqlevel // 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).toBeDefined()
expect(reqLevelLine?.color).toBe('red') expect(reqLevelLine?.color).toBe('red')
// Prerequisites: red when unmet // Prerequisites: red when unmet
const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈')) const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈'))
expect(prereqLine).toBeDefined() expect(prereqLine).toBeDefined()
expect(prereqLine?.color).toBe('red') expect(prereqLine?.color).toBe('red')
// Unlearned skill MUST NOT include [下一等级] preview // Unlearned skill MUST NOT include 下一等級 preview
expect(unlearnedLines.some(l => l.text.includes('[下一等级]'))).toBe(false) expect(unlearnedLines.some(l => l.text.includes('下一等級'))).toBe(false)
// 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1) // 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1)
const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1) const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1)
expect(fireBolt).not.toBeNull() expect(fireBolt).not.toBeNull()
const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!) const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!)
// When level is met, reqlevel line is omitted from tooltip // 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 // 3. Learned skill (slvl = 1) with next-level preview in green
const learnedBuild = createInitialState('sor') const learnedBuild = createInitialState('sor')
@ -631,11 +627,11 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
expect(learnedFireBolt).not.toBeNull() expect(learnedFireBolt).not.toBeNull()
const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!) 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).toBeDefined()
expect(currLevelLine?.color).toBe('white') 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).toBeDefined()
expect(nextLevelHeader?.color).toBe('green') 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('火焰伤害')) const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害'))
expect(nextLevelDmg).toBeDefined() 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') const maxedBuild = createInitialState('sor')
allocatePoint(maxedBuild, 36, 20) allocatePoint(maxedBuild, 36, 20)
const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20) const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20)
expect(maxedFireBolt).not.toBeNull() expect(maxedFireBolt).not.toBeNull()
const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!) const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!)
expect(maxedLines.some(l => l.text.includes('[下一等级]'))).toBe(false) 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')
// 5. Synergy section formatting // 5. Synergy section formatting
const fireballBuild = createInitialState('sor') const fireballBuild = createInitialState('sor')
allocatePoint(fireballBuild, 36) // Fire Bolt +1 allocatePoint(fireballBuild, 36) // Fire Bolt +1
const fbVm = buildTooltipViewModel(fireballBuild, 47, 12) const fbVm = buildTooltipViewModel(fireballBuild, 47, 12)
const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!) 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).toBeDefined()
expect(synHeader?.color).toBe('blue') expect(synHeader?.color).toBe('white')
const synFireBolt = fbLines.find(l => l.text.includes('火彈: 每个有效技能点增加 14% 伤害')) const synFireBolt = fbLines.find(l => l.text.includes('火彈: +14% 每等級傷害'))
expect(synFireBolt).toBeDefined() expect(synFireBolt).toBeDefined()
expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point) 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).toBeDefined()
expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points) 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() expect(inv.cursorItem).toBeNull()
// 8. Horadric Cube 3x4 grid placement and pickup (`CUBE_GRID_ORIGIN`) // 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 // Cannot place Horadric Cube ('box') inside Horadric Cube
const boxItem = inv.gridItems.find(p => p.item.code === 'box')!.item const boxItem = inv.gridItems.find(p => p.item.code === 'box')!.item
expect(worldPanels.clickCubeCell(0, 0, boxItem).handled).toBe(false) expect(worldPanels.clickCubeCell(0, 0, boxItem).handled).toBe(false)