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:
parent
2733a720ae
commit
171f7c00fc
File diff suppressed because it is too large
Load Diff
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue