546 lines
23 KiB
TypeScript
546 lines
23 KiB
TypeScript
/**
|
|
* tests/challenger-m3-npc-panels-stress.test.ts
|
|
*
|
|
* Diablo II v1.13c Batch 3 Parity Gate — Challenger 2 Stress Suite
|
|
*
|
|
* Empirical verification & adversarial stress testing for:
|
|
* 1. MonStats hcIdx alignment and town NPC automap dot discrimination.
|
|
* 2. NPC dialogue & menu options quest prerequisite gating (Cain, Tyrael, Larzuk, Akara respec).
|
|
* 3. Network protocol packet dispatch & double-packet regression fuzzing.
|
|
* 4. Left-panel inventory layouts (Hireling2, Trade grids) & tooltip bounds.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
WorldPanelsHud,
|
|
buildNpcMenuOptions,
|
|
computeNpcDialogLayout,
|
|
type NpcMenuContext,
|
|
type NpcMenuOption,
|
|
} from '../src/client/ui/world-panels.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'
|
|
import {
|
|
TOWN_NPC_DESCRIPTORS,
|
|
NPC_HCIDX_TO_DESCRIPTOR_ID,
|
|
getTownNpcDescriptorByHcIdx,
|
|
getTownNpcDescriptorByName,
|
|
VendorNpcId,
|
|
} from '../src/common/units/npc-table.ts'
|
|
import {
|
|
MONSTER_HCIDX_TO_ID,
|
|
MONSTER_ID_TO_HCIDX,
|
|
} from '../src/common/units/monster-mapping.ts'
|
|
import type { ClientCommand } from '../src/netproto/index.ts'
|
|
|
|
// --- Test Utilities & Environment Setup ---
|
|
|
|
function setupHudEnvironment() {
|
|
const world = new ClientWorld()
|
|
const hud = new HudManager()
|
|
const commands: ClientCommand[] = []
|
|
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
|
|
return { world, hud, model, commands }
|
|
}
|
|
|
|
describe('Challenger 2 Empirical Stress Test Harness — NPC Dialogue & World Panels', () => {
|
|
|
|
// =========================================================================
|
|
// SUITE 1: MonStats hcIdx & Town NPC Dot Discrimination Oracle
|
|
// =========================================================================
|
|
describe('Suite 1: MonStats hcIdx & Town NPC Dot Discrimination Oracle', () => {
|
|
|
|
it('1.1 Act 5 Town NPCs and interactables must have exact hcIdx parity with MONSTER_HCIDX_TO_ID', () => {
|
|
// In Diablo II v1.13c, MONSTER_HCIDX_TO_ID defines canonical row indices without Expansion separator.
|
|
// npc-table.ts MUST match monster-mapping.ts exactly for all town NPCs and interactables.
|
|
const act5ParityExpectations: Array<{ key: string; expectedHcIdx: number; name: string }> = [
|
|
{ key: 'larzuk', expectedHcIdx: 511, name: 'Larzuk' },
|
|
{ key: 'drehya', expectedHcIdx: 512, name: 'Drehya / Anya' },
|
|
{ key: 'malah', expectedHcIdx: 513, name: 'Malah' },
|
|
{ key: 'nihlathak', expectedHcIdx: 514, name: 'Nihlathak' },
|
|
{ key: 'qual-kehk', expectedHcIdx: 515, name: 'Qual-Kehk' },
|
|
{ key: 'cain6', expectedHcIdx: 520, name: 'Deckard Cain (Act 5)' },
|
|
{ key: 'tyrael3', expectedHcIdx: 521, name: 'Tyrael (Act 5)' },
|
|
{ key: 'drehyaiced', expectedHcIdx: 527, name: 'Anya Frozen in Ice' },
|
|
{ key: 'ancientstatue1', expectedHcIdx: 537, name: 'Ancient Statue 1 (Talic)' },
|
|
{ key: 'ancientstatue2', expectedHcIdx: 538, name: 'Ancient Statue 2 (Madawc)' },
|
|
{ key: 'ancientstatue3', expectedHcIdx: 539, name: 'Ancient Statue 3 (Korlic)' },
|
|
]
|
|
|
|
for (const exp of act5ParityExpectations) {
|
|
const canonicalIdFromMapping = MONSTER_HCIDX_TO_ID[exp.expectedHcIdx]
|
|
expect(
|
|
canonicalIdFromMapping,
|
|
`Ground truth monster-mapping at hcIdx ${exp.expectedHcIdx} must be ${exp.key}`,
|
|
).toBe(exp.key)
|
|
|
|
// Verify that npc-table matches this exact hcIdx
|
|
const descByHcIdx = getTownNpcDescriptorByHcIdx(exp.expectedHcIdx)
|
|
expect(
|
|
descByHcIdx,
|
|
`getTownNpcDescriptorByHcIdx(${exp.expectedHcIdx}) must find descriptor for ${exp.key}`,
|
|
).toBeDefined()
|
|
|
|
const mappedKey = NPC_HCIDX_TO_DESCRIPTOR_ID[exp.expectedHcIdx]
|
|
expect(
|
|
mappedKey,
|
|
`NPC_HCIDX_TO_DESCRIPTOR_ID[${exp.expectedHcIdx}] must map to ${exp.key}`,
|
|
).toBe(exp.key)
|
|
}
|
|
})
|
|
|
|
it('1.2 Hostile boss encounters (Ancient Barbarians 540-542) must NEVER be mapped as town NPCs', () => {
|
|
// 540 = ancientbarb1, 541 = ancientbarb2, 542 = ancientbarb3
|
|
expect(MONSTER_HCIDX_TO_ID[540]).toBe('ancientbarb1')
|
|
expect(MONSTER_HCIDX_TO_ID[541]).toBe('ancientbarb2')
|
|
expect(MONSTER_HCIDX_TO_ID[542]).toBe('ancientbarb3')
|
|
|
|
// Neither 540, 541, nor 542 should ever be in NPC_HCIDX_TO_DESCRIPTOR_ID
|
|
expect(
|
|
NPC_HCIDX_TO_DESCRIPTOR_ID.has(540),
|
|
'ancientbarb1 (540) must NOT be registered in NPC_HCIDX_TO_DESCRIPTOR_ID',
|
|
).toBe(false)
|
|
expect(
|
|
NPC_HCIDX_TO_DESCRIPTOR_ID[540],
|
|
'ancientbarb1 (540) must NOT map to any town NPC descriptor',
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('1.3 Automap entity dot discrimination oracle: ancient statues and bosses must not emit town NPC dots', () => {
|
|
// Simulate automap-view.ts entity dot discrimination logic
|
|
function simulateAutomapDot(classId: number): 'npc' | 'other' {
|
|
const isAncientStatue = classId >= 537 && classId <= 539
|
|
const isTownNpc = Boolean(
|
|
NPC_HCIDX_TO_DESCRIPTOR_ID.has(classId) || NPC_HCIDX_TO_DESCRIPTOR_ID[classId],
|
|
)
|
|
const isInteract = isTownNpc
|
|
if (isInteract && !isAncientStatue) {
|
|
return 'npc'
|
|
}
|
|
return 'other'
|
|
}
|
|
|
|
// Valid town NPCs must emit 'npc'
|
|
expect(simulateAutomapDot(511), 'Larzuk (511) must emit npc dot').toBe('npc')
|
|
expect(simulateAutomapDot(513), 'Malah (513) must emit npc dot').toBe('npc')
|
|
expect(simulateAutomapDot(515), 'Qual-Kehk (515) must emit npc dot').toBe('npc')
|
|
|
|
// Ancient statues 537, 538, 539 must NEVER emit 'npc'
|
|
expect(simulateAutomapDot(537), 'Ancient Statue 1 (537) must not emit npc dot').toBe('other')
|
|
expect(simulateAutomapDot(538), 'Ancient Statue 2 (538) must not emit npc dot').toBe('other')
|
|
expect(simulateAutomapDot(539), 'Ancient Statue 3 (539) must not emit npc dot').toBe('other')
|
|
|
|
// Ancient Barbarian boss 540 must NEVER emit 'npc'
|
|
expect(simulateAutomapDot(540), 'Ancient Barbarian Boss 1 (540) must not emit npc dot').toBe('other')
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 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', () => {
|
|
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')
|
|
})
|
|
|
|
it('2.2 Kashya hire gating: gated by Blood Raven (A1Q2) complete OR player level >= 9', () => {
|
|
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)
|
|
})
|
|
|
|
it('2.3 Qual-Kehk hire gating: gated strictly by Rescue on Mount Arreat (A5Q2 = index 36)', () => {
|
|
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)
|
|
})
|
|
|
|
it('2.4 Act Boss Travel gating: 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
|
|
it('2.5 Quest reward gating: Imbue (Charsi), Add Sockets (Larzuk), Personalize (Anya)', () => {
|
|
const charsi = getTownNpcDescriptorByName('charsi')
|
|
const larzuk = getTownNpcDescriptorByName('larzuk')
|
|
const anya = getTownNpcDescriptorByName('drehya')
|
|
|
|
// Imbue: A1Q5 (index 5) — available when (& 2) !== 0 and (& 1) === 0
|
|
if (charsi) {
|
|
const qUnearned = new Uint8Array(48)
|
|
qUnearned[5] = 0
|
|
expect(buildNpcMenuOptions(charsi, { questFlags: qUnearned }).some(o => o.id === 'imbue')).toBe(false)
|
|
|
|
const qEarned = new Uint8Array(48)
|
|
qEarned[5] = 2 // earned, not yet used
|
|
expect(buildNpcMenuOptions(charsi, { questFlags: qEarned }).some(o => o.id === 'imbue')).toBe(true)
|
|
|
|
const qUsed = new Uint8Array(48)
|
|
qUsed[5] = 3 // bit 0 set -> consumed
|
|
expect(buildNpcMenuOptions(charsi, { questFlags: qUsed }).some(o => o.id === 'imbue')).toBe(false)
|
|
}
|
|
|
|
// Add Sockets: A5Q1 (index 35) — available when (& 2) !== 0 and (& 1) === 0
|
|
if (larzuk) {
|
|
const qEarned = new Uint8Array(48)
|
|
qEarned[35] = 2
|
|
expect(buildNpcMenuOptions(larzuk, { questFlags: qEarned }).some(o => o.id === 'add_sockets')).toBe(true)
|
|
|
|
const qUsed = new Uint8Array(48)
|
|
qUsed[35] = 3
|
|
expect(buildNpcMenuOptions(larzuk, { questFlags: qUsed }).some(o => o.id === 'add_sockets')).toBe(false)
|
|
}
|
|
|
|
// Personalize: A5Q3 (index 38) — available when (& 2) !== 0 and (& 1) === 0
|
|
if (anya) {
|
|
const qEarned = new Uint8Array(48)
|
|
qEarned[38] = 2
|
|
expect(buildNpcMenuOptions(anya, { questFlags: qEarned }).some(o => o.id === 'personalize')).toBe(true)
|
|
|
|
const qUsed = new Uint8Array(48)
|
|
qUsed[38] = 3
|
|
expect(buildNpcMenuOptions(anya, { questFlags: qUsed }).some(o => o.id === 'personalize')).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('2.6 Akara respec action gating and HudModel handling oracle', () => {
|
|
const akara = getTownNpcDescriptorByName('akara')
|
|
expect(akara).toBeDefined()
|
|
if (!akara) return
|
|
|
|
// Den of Evil incomplete -> no respec
|
|
const qIncomplete = new Uint8Array(48)
|
|
qIncomplete[1] = 0
|
|
qIncomplete[41] = 0
|
|
expect(buildNpcMenuOptions(akara, { questFlags: qIncomplete }).some(o => o.id === 'respec')).toBe(false)
|
|
|
|
// Den of Evil complete (q[1] & 1 !== 0), respec unconsumed -> respec available
|
|
const qComplete = new Uint8Array(48)
|
|
qComplete[1] = 1
|
|
qComplete[41] = 0
|
|
expect(buildNpcMenuOptions(akara, { questFlags: qComplete }).some(o => o.id === 'respec')).toBe(true)
|
|
|
|
// Respec consumed (q[41] & 1 !== 0) -> no respec
|
|
const qConsumed = new Uint8Array(48)
|
|
qConsumed[1] = 1
|
|
qConsumed[41] = 1
|
|
expect(buildNpcMenuOptions(akara, { questFlags: qConsumed }).some(o => o.id === 'respec')).toBe(false)
|
|
|
|
// CRITICAL HUDMODEL ORACLE:
|
|
// hudModel.handleNpcMenuAction('respec') MUST handle respec by dispatching NpcEntityAction(0x0c)
|
|
const { model, commands } = setupHudEnvironment()
|
|
model.handleNpcMenuAction('respec', 101, akara)
|
|
expect(
|
|
commands.length,
|
|
'hudModel.handleNpcMenuAction("respec") must NOT silently drop the action',
|
|
).toBe(1)
|
|
expect(commands[0]).toEqual({
|
|
type: 'NpcEntityAction',
|
|
action: 12,
|
|
npcId: 101,
|
|
complement: 0,
|
|
})
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// SUITE 3: Protocol Packet Dispatch & Double-Packet Fuzzing
|
|
// =========================================================================
|
|
describe('Suite 3: Protocol Packet Dispatch & Double-Packet Fuzzing', () => {
|
|
|
|
it('3.1 Clicking "talk" option on NPC menu must dispatch EXACTLY ONE QuestMessage packet', () => {
|
|
const { hud, commands } = setupHudEnvironment()
|
|
const akara = getTownNpcDescriptorByName('akara')
|
|
expect(akara).toBeDefined()
|
|
if (!akara) return
|
|
|
|
// Open Akara menu
|
|
hud.worldPanels.openNpcMenu(akara, 400, 300, 101)
|
|
expect(hud.worldPanels.npcMenu).toBeDefined()
|
|
|
|
// Record commands before click
|
|
const initialCommandCount = commands.length
|
|
|
|
// Click "talk" option
|
|
const handled = hud.worldPanels.clickNpcMenuOption('talk')
|
|
expect(handled).toBe(true)
|
|
|
|
// DISPATCH INTEGRITY ASSERTION:
|
|
// Must dispatch exactly ONE QuestMessage packet. Double-packet regression triggers server desync!
|
|
const newCommands = commands.slice(initialCommandCount)
|
|
expect(
|
|
newCommands.length,
|
|
`Expected exactly 1 packet dispatched on clicking talk, but received ${newCommands.length}: ${JSON.stringify(newCommands)}`,
|
|
).toBe(1)
|
|
|
|
expect(newCommands[0]).toEqual({
|
|
type: 'QuestMessage',
|
|
npcId: 101,
|
|
messageId: 0,
|
|
})
|
|
})
|
|
|
|
it('3.2 Differential fuzzing across all menu options: packet count must strictly equal 1 without duplicates', () => {
|
|
const qFlags = new Uint8Array(48)
|
|
// Den of evil complete (q[1]=1), respec unconsumed (q[41]=0)
|
|
qFlags[1] = 1
|
|
qFlags[41] = 0
|
|
// Blood raven complete (q[2]=1)
|
|
qFlags[2] = 1
|
|
// Andariel complete (q[6]=1)
|
|
qFlags[6] = 1
|
|
|
|
const testCases: Array<{
|
|
npcKey: string
|
|
npcId: number
|
|
optionId: string
|
|
expectedCommandType: string
|
|
}> = [
|
|
{ npcKey: 'akara', npcId: 101, optionId: 'respec', expectedCommandType: 'NpcEntityAction' },
|
|
{ npcKey: 'kashya', npcId: 102, optionId: 'hire', expectedCommandType: 'NpcEntityAction' },
|
|
{ npcKey: 'warriv1', npcId: 103, optionId: 'travel_east', expectedCommandType: 'NpcEntityAction' },
|
|
{ npcKey: 'charsi', npcId: 104, optionId: 'trade', expectedCommandType: 'NpcEntityAction' },
|
|
{ npcKey: 'gheed', npcId: 105, optionId: 'gamble', expectedCommandType: 'NpcEntityAction' },
|
|
]
|
|
|
|
for (const tc of testCases) {
|
|
const { hud, commands } = setupHudEnvironment()
|
|
const desc = getTownNpcDescriptorByName(tc.npcKey)
|
|
expect(desc).toBeDefined()
|
|
if (!desc) continue
|
|
|
|
// Force open menu with options
|
|
hud.worldPanels.openNpcMenu(desc, 400, 300, tc.npcId, {
|
|
playerLevel: 90,
|
|
questFlags: qFlags,
|
|
})
|
|
|
|
const beforeCount = commands.length
|
|
const handled = hud.worldPanels.clickNpcMenuOption(tc.optionId)
|
|
expect(handled, `Option ${tc.optionId} should be handled for ${tc.npcKey}`).toBe(true)
|
|
|
|
const dispatched = commands.slice(beforeCount)
|
|
expect(
|
|
dispatched.length,
|
|
`Option ${tc.optionId} on ${tc.npcKey} must dispatch exactly 1 command, got ${dispatched.length}`,
|
|
).toBe(1)
|
|
expect(dispatched[0].type).toBe(tc.expectedCommandType)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// SUITE 4: Left-Panel Inventory Layouts & Tooltip Bounds
|
|
// =========================================================================
|
|
describe('Suite 4: Left-Panel Inventory Layouts & Tooltip Bounds', () => {
|
|
|
|
it('4.1 Hireling2 equipment slots must match canonical inventory.txt coordinates and handle clicks accurately', () => {
|
|
const { hud } = setupHudEnvironment()
|
|
|
|
// inventory.txt Hireling2 canonical slots:
|
|
const canonicalSlots = [
|
|
{ slot: 1, name: 'Head', x: 215, y: 68, w: 54, h: 51 },
|
|
{ slot: 3, name: 'Torso', x: 213, y: 137, w: 56, h: 82 },
|
|
{ slot: 4, name: 'Weapon', x: 100, y: 107, w: 55, h: 112 },
|
|
{ slot: 5, name: 'Shield', x: 331, y: 107, w: 55, h: 112 },
|
|
]
|
|
|
|
hud.worldPanels.activePanel = 'merc'
|
|
|
|
for (const s of canonicalSlots) {
|
|
let clickedSlot: number | null = null
|
|
const callbacks = {
|
|
onMercItem: (slot: number) => {
|
|
clickedSlot = slot
|
|
},
|
|
}
|
|
|
|
// Test center of slot
|
|
const centerX = s.x + Math.floor(s.w / 2)
|
|
const centerY = s.y + Math.floor(s.h / 2)
|
|
const hitCenter = hud.worldPanels.handleActiveLeftPanelClick(centerX, centerY, callbacks)
|
|
expect(hitCenter).toBe(true)
|
|
expect(clickedSlot, `Center click on ${s.name} slot (${centerX}, ${centerY}) must target slot ${s.slot}`).toBe(s.slot)
|
|
|
|
// Test boundary minimum corner
|
|
clickedSlot = null
|
|
const hitMin = hud.worldPanels.handleActiveLeftPanelClick(s.x, s.y, callbacks)
|
|
expect(hitMin).toBe(true)
|
|
expect(clickedSlot, `Min corner click on ${s.name} slot (${s.x}, ${s.y}) must target slot ${s.slot}`).toBe(s.slot)
|
|
|
|
// Test boundary maximum corner
|
|
clickedSlot = null
|
|
const hitMax = hud.worldPanels.handleActiveLeftPanelClick(s.x + s.w, s.y + s.h, callbacks)
|
|
expect(hitMax).toBe(true)
|
|
expect(clickedSlot, `Max corner click on ${s.name} slot (${s.x + s.w}, ${s.y + s.h}) must target slot ${s.slot}`).toBe(s.slot)
|
|
}
|
|
})
|
|
|
|
it('4.2 Trade grids must match canonical inventory.txt Page 1-2 (100, 101, 290, 116) and Page 2-2 (100, 315, 290, 116)', () => {
|
|
const { hud } = setupHudEnvironment()
|
|
hud.worldPanels.activePanel = 'trade'
|
|
|
|
// Player trade grid: (100, 315, 290, 116) — 10 columns by 4 rows of 29px
|
|
// Test all 40 cell coordinates (col 0..9, row 0..3)
|
|
for (let col = 0; col < 10; col++) {
|
|
for (let row = 0; row < 4; row++) {
|
|
let receivedCol: number | null = null
|
|
let receivedRow: number | null = null
|
|
|
|
const callbacks = {
|
|
onTradeGridClick: (c: number, r: number) => {
|
|
receivedCol = c
|
|
receivedRow = r
|
|
},
|
|
}
|
|
|
|
const clickX = 100 + col * 29 + 14 // cell center X
|
|
const clickY = 315 + row * 29 + 14 // cell center Y
|
|
|
|
const handled = hud.worldPanels.handleActiveLeftPanelClick(clickX, clickY, callbacks)
|
|
expect(handled).toBe(true)
|
|
expect(receivedCol, `Click at (${clickX}, ${clickY}) should map to col ${col}`).toBe(col)
|
|
expect(receivedRow, `Click at (${clickX}, ${clickY}) should map to row ${row}`).toBe(row)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('4.3 Tooltip and NPC dialog layout calculation: bounds must never produce NaN or coordinate overflow', () => {
|
|
const options: readonly NpcMenuOption[] = [
|
|
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'Talk' },
|
|
{ id: 'trade', strIdx: 3396, label: '交易', labelZh: '交易', labelEn: 'Trade' },
|
|
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'Cancel' },
|
|
]
|
|
|
|
// Sweep coordinates from extremes
|
|
const testCoordinates = [
|
|
{ x: 0, y: 0 },
|
|
{ x: 400, y: 300 },
|
|
{ x: 799, y: 599 },
|
|
{ x: -100, y: -100 },
|
|
{ x: 1000, y: 800 },
|
|
]
|
|
|
|
for (const pt of testCoordinates) {
|
|
const layout = computeNpcDialogLayout('Deckard Cain', options, pt.x, pt.y)
|
|
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)
|
|
expect(Number.isFinite(layout.height), 'layout.height must be finite').toBe(true)
|
|
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])
|
|
expect(layout.left).toBeGreaterThanOrEqual(10)
|
|
expect(layout.top).toBeGreaterThanOrEqual(10)
|
|
}
|
|
})
|
|
})
|
|
})
|