diablo2-web/tests/client/world-panels-batch6.test.ts

591 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/)
})
})
})