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 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.',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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)')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue