diablo2-web/tests/challenger-m3-npc-panels-st...

489 lines
21 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 { D2FontRenderer } from '../src/client/ui/font.ts'
import { HudManager } from '../src/client/ui/hud-manager.ts'
import { HudModel } from '../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../src/client/world/client-world.ts'
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 static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): always [Talk, Identify Items (3399), Cancel]', () => {
const cain = getTownNpcDescriptorByName('cain1')
expect(cain).toBeDefined()
if (!cain) return
expect(
cain.canIdentify,
'cain1 (Act 1 Deckard Cain) must have canIdentify: true per Diablo II 1.13c canon',
).toBe(true)
const ctxNoUnid: NpcMenuContext = {
hasUnidentifiedItems: false,
unidentifiedItemCount: 0,
questFlags: new Uint8Array(48),
}
const optsNoUnid = buildNpcMenuOptions(cain, ctxNoUnid)
const identifyNoUnid = optsNoUnid.find(o => o.id === 'identify')
expect(identifyNoUnid).toBeDefined()
expect(identifyNoUnid?.strIdx).toBe(3399)
expect(identifyNoUnid?.labelEn).toBe('Identify Items')
})
it('2.2 Kashya static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): always [Talk, Hire, Cancel]', () => {
const kashya = getTownNpcDescriptorByName('kashya')
expect(kashya).toBeDefined()
if (!kashya) return
const qIncomplete = new Uint8Array(48)
qIncomplete[2] = 0
const optsLvl1 = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 1 })
expect(optsLvl1.map(o => o.id)).toEqual(['talk', 'hire', 'cancel'])
})
it('2.3 Qual-Kehk static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): always [Talk, Hire, Cancel]', () => {
const qualKehk = getTownNpcDescriptorByName('qual-kehk')
expect(qualKehk).toBeDefined()
if (!qualKehk) return
const qIncomplete = new Uint8Array(48)
qIncomplete[36] = 0
const optsIncomplete = buildNpcMenuOptions(qualKehk, { questFlags: qIncomplete, playerLevel: 90 })
expect(optsIncomplete.map(o => o.id)).toEqual(['talk', 'hire', 'cancel'])
})
it('2.4 Act Boss Travel static menu options per D2Client.dll 0x6FB1EC00 (Issue #666): Warriv, Meshif, and Tyrael', () => {
const warriv1 = getTownNpcDescriptorByName('warriv1')
const meshif1 = getTownNpcDescriptorByName('meshif1')
const tyrael1 = getTownNpcDescriptorByName('tyrael1')
if (warriv1) {
const q0 = new Uint8Array(48)
expect(buildNpcMenuOptions(warriv1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
}
if (meshif1) {
const q0 = new Uint8Array(48)
expect(buildNpcMenuOptions(meshif1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_east', 'cancel'])
}
if (tyrael1) {
const q0 = new Uint8Array(48)
expect(buildNpcMenuOptions(tyrael1, { questFlags: q0 }).map(o => o.id)).toEqual(['talk', 'travel_harrogath', 'cancel'])
}
})
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 },
]
const font = new D2FontRenderer()
for (const pt of testCoordinates) {
const layout = computeNpcDialogLayout('Deckard Cain', options, pt.x, pt.y, font)
expect(Number.isFinite(layout.left), 'layout.left must be finite').toBe(true)
expect(Number.isFinite(layout.top), 'layout.top must be finite').toBe(true)
expect(Number.isFinite(layout.width), 'layout.width must be finite').toBe(true)
expect(Number.isFinite(layout.height), 'layout.height must be finite').toBe(true)
expect(layout.width).toBeGreaterThan(0)
expect(layout.height).toBeGreaterThan(0)
// Must respect DIALOG_Layout viewport clamping bounds (left in [10, 800 - width], top in [10, 552 - height])
expect(layout.left).toBeGreaterThanOrEqual(10)
expect(layout.left).toBeLessThanOrEqual(800 - layout.width)
expect(layout.top).toBeGreaterThanOrEqual(10)
}
})
})
})