diablo2-web/tests/e2e-d2gs-online/helpers.ts

1420 lines
42 KiB
TypeScript

import { vi } from 'vitest'
import { AutomapView } from '../../src/client/automap/automap-view.ts'
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
import type { LevelView } from '../../src/client/map/level-view.ts'
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import * as hudModelMod from '../../src/client/ui-model/hud-model.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import { vendorPrice113c, vendorRepairAll113c } from '../../src/client/ui-model/vendor-price.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import type { InventoryPanel } from '../../src/client/ui/inventory.ts'
import * as worldPanelsMod from '../../src/client/ui/world-panels.ts'
import {
WorldPanelsHud,
type NpcMenuOption,
} from '../../src/client/ui/world-panels.ts'
import { ClientWorldSceneSource } from '../../src/client/view/scene-source.ts'
import {
buildSceneDrawables as baseBuildSceneDrawables,
type BuildSceneDrawablesResult,
type RenderableUnitInput,
type SceneUnitDrawable as BaseSceneUnitDrawable,
} from '../../src/client/view/unit-drawables.ts'
import { ClientWorld as BaseClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId as BaseD2StatId } from '../../src/client/world/stat-list.ts'
import { getEmbeddedDropTables } from '../../src/common/items/embedded-drop-tables.ts'
import * as groundItemsMod from '../../src/common/items/ground-items.ts'
import {
resolveGroundItemMetadata as baseResolveGroundItemMetadata,
type GroundItemMetadata,
} from '../../src/common/items/ground-items.ts'
import type { UiInventoryItem as BaseUiInventoryItem } from '../../src/common/items/inventory-model.ts'
import * as itemBridgeMod from '../../src/common/items/item-bridge.ts'
import * as npcTableMod from '../../src/common/units/npc-table.ts'
import {
getTownNpcDescriptorByHcIdx as baseGetTownNpcDescriptorByHcIdx,
getTownNpcDescriptorByName,
TOWN_NPC_DESCRIPTORS as BASE_TOWN_NPC_DESCRIPTORS,
type TownNpcServiceDescriptor,
} from '../../src/common/units/npc-table.ts'
import {
C2S_PACKET_SIZES,
ClickButtonId,
clientCommandToC2sPackets,
decodeD2gsS2cPacket,
encodeD2gsClientCommand,
NpcActionId as BaseNpcActionId,
validateC2sPacket,
type ClientCommand,
type ItemData,
type ItemStat,
type ServerEvent,
} from '../../src/netproto/index.ts'
export const NpcActionId = {
...BaseNpcActionId,
OpenHireList: BaseNpcActionId.Hire,
} as const
export const D2StatId = {
...BaseD2StatId,
STRENGTH: BaseD2StatId.Strength,
ENERGY: BaseD2StatId.Energy,
DEXTERITY: BaseD2StatId.Dexterity,
VITALITY: BaseD2StatId.Vitality,
STATPTS: BaseD2StatId.StatPts,
NEWSKILLS: BaseD2StatId.NewSkills,
HITPOINTS: BaseD2StatId.HitPoints,
MAXHP: BaseD2StatId.MaxHp,
MANA: BaseD2StatId.Mana,
MAXMANA: BaseD2StatId.MaxMana,
STAMINA: BaseD2StatId.Stamina,
MAXSTAMINA: BaseD2StatId.MaxStamina,
LEVEL: BaseD2StatId.Level,
EXPERIENCE: BaseD2StatId.Experience,
GOLD: BaseD2StatId.Gold,
GOLDBANK: BaseD2StatId.GoldBank,
ARMORCLASS: BaseD2StatId.ArmorClass,
FIRERESIST: BaseD2StatId.FireResist,
LIGHTRESIST: BaseD2StatId.LightResist,
COLDRESIST: BaseD2StatId.ColdResist,
POISONRESIST: BaseD2StatId.PoisonResist,
Quantity: 70,
QUANTITY: 70,
Durability: 72,
DURABILITY: 72,
MaxDurability: 73,
MAXDURABILITY: 73,
AllSkills: 127,
ALL_SKILLS: 127,
} as const
export type NpcMenuActionId =
| 'talk'
| 'trade'
| 'trade_repair'
| 'gamble'
| 'identify'
| 'hire'
| 'resurrect'
| 'cancel'
export type SceneUnitDrawable = BaseSceneUnitDrawable & {
readonly id: number
}
export function buildSceneDrawables(
units: Iterable<RenderableUnitInput>,
optionsOrWidth?: Record<string, any> | number,
_height?: number,
): Omit<BuildSceneDrawablesResult, 'unitDrawables'> & {
readonly unitDrawables: readonly SceneUnitDrawable[]
} {
const opts = typeof optionsOrWidth === 'object' ? optionsOrWidth : undefined
const res = baseBuildSceneDrawables(units, opts)
return {
...res,
unitDrawables: res.unitDrawables.map((d) => ({
...d,
id: d.unitId,
})),
}
}
export type UiInventoryItem = Omit<BaseUiInventoryItem, 'id'> & {
readonly id: any
readonly nameEn: string
readonly baseName: string
readonly usedSockets: number
readonly damageRange?: { readonly min: number; readonly max: number } | undefined
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
}
export type ExtendedTownNpcServiceDescriptor = TownNpcServiceDescriptor & {
readonly greetingKey: string
}
export const TOWN_NPC_DESCRIPTORS: ReadonlyArray<ExtendedTownNpcServiceDescriptor> &
Readonly<Record<string, ExtendedTownNpcServiceDescriptor>> = (() => {
const arr = BASE_TOWN_NPC_DESCRIPTORS.map((d) => ({
...d,
greetingKey: (d as Record<string, any>).greetingKey ?? d.npcTxtKey,
}))
const obj = arr as unknown as ExtendedTownNpcServiceDescriptor[] &
Record<string, ExtendedTownNpcServiceDescriptor>
for (const d of arr) {
obj[d.npcTxtKey] = d
}
return obj
})()
export function getTownNpcDescriptor(
idOrName: string,
actIndex?: number,
): ExtendedTownNpcServiceDescriptor | undefined {
const fn =
(npcTableMod as Record<string, any>).getTownNpcDescriptor ?? getTownNpcDescriptorByName
const res: TownNpcServiceDescriptor | undefined =
fn(idOrName, actIndex) ??
BASE_TOWN_NPC_DESCRIPTORS.find((d) => d.npcTxtKey === idOrName.toLowerCase())
if (!res) return undefined
return {
...res,
greetingKey: (res as Record<string, any>).greetingKey ?? res.npcTxtKey,
}
}
export function getTownNpcDescriptorByHcIdx(
hcIdx: number,
): ExtendedTownNpcServiceDescriptor | null {
const res = baseGetTownNpcDescriptorByHcIdx(hcIdx)
if (!res) return null
return {
...res,
greetingKey: (res as Record<string, any>).greetingKey ?? res.npcTxtKey,
}
}
export function buildNpcMenuOptions(
descriptor: TownNpcServiceDescriptor,
context?: {
readonly hasUnidentifiedItems?: boolean
readonly hasDeadMercenary?: boolean
readonly deadMercenaryReviveCost?: number
},
): readonly NpcMenuOption[] {
const modFn = (worldPanelsMod as Record<string, any>).buildNpcMenuOptions
if (typeof modFn === 'function') {
return modFn(descriptor, context)
}
const hud = new WorldPanelsHud()
const baseOpts = hud.buildNpcMenuOptions(descriptor)
const result: NpcMenuOption[] = []
for (const opt of baseOpts) {
const optId = opt.id as string
if (optId === 'identify' && context && context.hasUnidentifiedItems === false) {
continue
}
if (optId === 'resurrect') {
if (context && context.hasDeadMercenary === false) {
continue
}
if (context?.deadMercenaryReviveCost !== undefined && context.deadMercenaryReviveCost > 0) {
result.push({
...opt,
labelEn: `${opt.labelEn} (${context.deadMercenaryReviveCost})`,
labelZh: `${opt.labelZh} (${context.deadMercenaryReviveCost})`,
})
continue
}
}
result.push(opt)
}
return result
}
export type ExtendedGroundItemMetadata = GroundItemMetadata & {
readonly labelEn: string
readonly labelZh: string
readonly color: string
}
export function resolveGroundItemMetadata(
itemInput: MakeItemDataInput | ItemData,
langOrNowMs?: 'ENG' | 'CHI' | number,
): ExtendedGroundItemMetadata {
const fullItem =
'itemId' in itemInput && 'opcode' in itemInput
? (itemInput as ItemData)
: makeItemData(itemInput)
const nowMs = typeof langOrNowMs === 'number' ? langOrNowMs : undefined
const base = baseResolveGroundItemMetadata(fullItem, nowMs)
const rec = base as Record<string, any>
return {
...base,
labelEn: rec.labelEn ?? base.name,
labelZh: rec.labelZh ?? base.nameZh,
color: rec.color ?? base.quality,
}
}
export interface GroundItemLabelInput {
readonly id: number
readonly text?: string | undefined
readonly label?: string | undefined
readonly color?: string | undefined
readonly screenX: number
readonly screenY: number
readonly width?: number | undefined
readonly height?: number | undefined
}
export interface GroundItemLabelBox {
readonly id: number
readonly text: string
readonly label: string
readonly color: string
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
export function layoutGroundItemLabels(
labels: readonly GroundItemLabelInput[],
viewportW = 800,
viewportH = 600,
): readonly GroundItemLabelBox[] {
const modFn =
(groundItemsMod as Record<string, any>).layoutGroundItemLabels ??
(worldPanelsMod as Record<string, any>).layoutGroundItemLabels
if (typeof modFn === 'function') {
return modFn(labels, viewportW, viewportH)
}
const placed: GroundItemLabelBox[] = []
for (const item of labels) {
const txt = item.text ?? item.label ?? ''
const w = item.width ?? Math.max(40, txt.length * 7 + 12)
const h = item.height ?? 18
let x = Math.max(0, Math.min(viewportW - w, Math.round(item.screenX - w / 2)))
let y = Math.max(0, Math.min(viewportH - h, Math.round(item.screenY - h)))
let safety = 0
while (
safety < 100 &&
placed.some(
(b) =>
x < b.x + b.width &&
x + w > b.x &&
y < b.y + b.height &&
y + h > b.y,
)
) {
y -= h + 2
safety++
}
placed.push({
id: item.id,
text: txt,
label: txt,
color: item.color ?? '#ffffff',
x,
y,
width: w,
height: h,
})
}
return placed
}
export function parseDamageRange(
dmg: string | { readonly min: number; readonly max: number } | undefined,
): { readonly min: number; readonly max: number } | undefined {
if (!dmg) return undefined
if (typeof dmg === 'object' && 'min' in dmg && 'max' in dmg) {
return { min: dmg.min, max: dmg.max }
}
const m = String(dmg).match(/(\d+)\s*(?:-|to)\s*(\d+)/i)
if (!m) return undefined
return { min: Number(m[1]), max: Number(m[2]) }
}
export type TestContainerStore = BaseClientWorld['containers'] & {
readonly tradeGrid: readonly ItemData[]
readonly partnerTradeGrid: readonly ItemData[]
}
function normalizeServerEvent(raw: ServerEvent | Record<string, any>): ServerEvent {
const ev = { ...raw } as Record<string, any>
if (ev.type === 'StatChange') {
return {
type: 'StatSet',
stat: ev.stat ?? ev.statId ?? 0,
value: ev.value ?? 0,
...(ev.param !== undefined ? { param: ev.param } : {}),
}
}
if (ev.type === 'PlayerLifeManaChange') {
return {
type: 'LifeMana',
life: ev.life ?? 0,
mana: ev.mana ?? 0,
stamina: ev.stamina ?? 0,
at: ev.at ?? { x: ev.x ?? 0, y: ev.y ?? 0 },
vx: ev.vx ?? 0,
vy: ev.vy ?? 0,
}
}
if (ev.type === 'WeaponSwitch') {
return {
type: 'ItemEvent',
action: 0x17,
container: 'equipment',
item: makeItemData({
id: 0,
code: ' ',
opcode: 0x9c,
action: 0x17,
}),
}
}
if (ev.type === 'UnitRemove' && ev.id === undefined && ev.unitId !== undefined) {
return {
...ev,
id: ev.unitId,
} as ServerEvent
}
if (ev.type === 'Party' && ev.unitId === undefined && ev.playerId !== undefined) {
return {
...ev,
unitId: ev.playerId,
} as ServerEvent
}
if (ev.type === 'Trade' && ev.kind === 'gold' && ev.action === undefined) {
return {
...ev,
action: 0x7900 | (ev.isSelf ? 1 : 0),
gold: ev.amount ?? ev.gold ?? 0,
} as ServerEvent
}
if (ev.type === 'ItemEvent' && ev.item) {
const it = ev.item as Record<string, any>
const fixedContainer = it.container === 'equipped' ? 'equipment' : it.container
return {
...ev,
action: ev.action ?? it.action ?? 0x00,
container: ev.container === 'equipped' ? 'equipment' : (ev.container ?? fixedContainer ?? 'inventory'),
item: {
...it,
container: fixedContainer,
},
} as ServerEvent
}
return ev as ServerEvent
}
export class ClientWorld extends BaseClientWorld {
override get containers(): TestContainerStore {
return super.containers as TestContainerStore
}
constructor() {
super()
Object.defineProperties(super.containers, {
tradeGrid: {
configurable: true,
get: () => [...super.containers.tradeSelf.values()],
},
partnerTradeGrid: {
configurable: true,
get: () => [...super.containers.tradePartner.values()],
},
})
}
override apply(rawEv: any): void {
super.apply(normalizeServerEvent(rawEv))
}
override noteOutgoingCommand(rawCmd: any): void {
const cmd = rawCmd as Record<string, any>
if (cmd && cmd.type === 'NpcCancel' && !cmd.target && cmd.npcId !== undefined) {
super.noteOutgoingCommand({
type: 'NpcCancel',
target: { unitType: 1, id: Number(cmd.npcId) },
})
return
}
super.noteOutgoingCommand(rawCmd as ClientCommand)
}
}
export {
AutomapView,
C2S_PACKET_SIZES,
ClickButtonId,
clientCommandToC2sPackets,
ClientWorldSceneSource,
CommandMapper,
decodeD2gsS2cPacket,
encodeD2gsClientCommand,
getEmbeddedDropTables,
HudManager,
HudModel,
LocalMovementPredictor,
OnlineSession,
validateC2sPacket,
ViewportCamera,
WorldPanelsHud,
type ClientCommand,
type ItemData,
type ItemStat,
type RenderableUnitInput,
type ServerEvent,
type TownNpcServiceDescriptor,
}
export interface DrawnTextEntry {
readonly text: string
readonly x: number
readonly y: number
readonly fillStyle: string
}
export interface MockCanvasContext extends CanvasRenderingContext2D {
readonly drawnTexts: DrawnTextEntry[]
readonly drawnRects: { x: number; y: number; w: number; h: number; fillStyle: string }[]
}
export function createMockCanvasContext(): MockCanvasContext {
const drawnTexts: DrawnTextEntry[] = []
const drawnRects: { x: number; y: number; w: number; h: number; fillStyle: string }[] = []
const ctx: Record<string, any> = {
drawnTexts,
drawnRects,
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
font: '12px sans-serif',
textAlign: 'left',
textBaseline: 'alphabetic',
globalAlpha: 1,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
drawnRects.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) })
}),
strokeRect: vi.fn(),
clearRect: vi.fn(),
fillText: vi.fn((text: string, x: number, y: number) => {
drawnTexts.push({ text: String(text), x, y, fillStyle: String(ctx.fillStyle) })
}),
strokeText: vi.fn(),
measureText: vi.fn((t: string) => ({ width: String(t).length * 7 })),
drawImage: vi.fn(),
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
setLineDash: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
rotate: vi.fn(),
putImageData: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 })),
createImageData: vi.fn((w: number, h: number) => ({
data: new Uint8ClampedArray(w * h * 4),
width: w,
height: h,
})),
}
return ctx as unknown as MockCanvasContext
}
export function setupMockDom(): {
readonly windowListeners: Map<string, ((e: any) => void)[]>
readonly dispatchKeyDown: (key: string, opts?: Record<string, any>) => void
readonly dispatchKeyUp: (key: string, opts?: Record<string, any>) => void
} {
const windowListeners = new Map<string, ((e: any) => void)[]>([])
const docElements = new Map<string, any>()
const makeElement = () => ({
style: {},
width: 800,
height: 600,
classList: { add: vi.fn(), remove: vi.fn(), toggle: vi.fn(), contains: () => false },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
appendChild: vi.fn(),
remove: vi.fn(),
setAttribute: vi.fn(),
getContext: () => createMockCanvasContext(),
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
innerHTML: '',
textContent: '',
})
vi.stubGlobal('window', {
innerWidth: 800,
innerHeight: 600,
addEventListener: vi.fn((ev: string, fn: (e: any) => void) => {
const list = windowListeners.get(ev) ?? []
list.push(fn)
windowListeners.set(ev, list)
}),
removeEventListener: vi.fn(),
performance: { now: () => 1000 },
})
vi.stubGlobal('document', {
getElementById: vi.fn((id: string) => {
if (!docElements.has(id)) docElements.set(id, makeElement())
return docElements.get(id)
}),
createElement: vi.fn(() => makeElement()),
body: makeElement(),
})
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
class MockImage {
complete = true
naturalWidth = 64
naturalHeight = 64
onload: (() => void) | null = null
onerror: ((err: unknown) => void) | null = null
private _src = ''
get src(): string {
return this._src
}
set src(val: string) {
this._src = val
if (this.onload) this.onload()
}
}
vi.stubGlobal('Image', MockImage)
const dispatchKeyDown = (key: string, opts: Record<string, any> = {}) => {
const handlers = windowListeners.get('keydown') ?? []
const ev = {
key,
code: opts.code ?? `Key${key.toUpperCase()}`,
shiftKey: Boolean(opts.shiftKey),
altKey: Boolean(opts.altKey),
ctrlKey: Boolean(opts.ctrlKey),
metaKey: Boolean(opts.metaKey),
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...opts,
}
for (const h of handlers) h(ev)
}
const dispatchKeyUp = (key: string, opts: Record<string, any> = {}) => {
const handlers = windowListeners.get('keyup') ?? []
const ev = {
key,
code: opts.code ?? `Key${key.toUpperCase()}`,
shiftKey: Boolean(opts.shiftKey),
altKey: Boolean(opts.altKey),
ctrlKey: Boolean(opts.ctrlKey),
metaKey: Boolean(opts.metaKey),
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...opts,
}
for (const h of handlers) h(ev)
}
return { windowListeners, dispatchKeyDown, dispatchKeyUp }
}
export function createStubLevelView(overrides: Partial<LevelView> = {}): LevelView {
const cellsX = 40
const cellsY = 40
const subWidth = cellsX * 5
const subHeight = cellsY * 5
const walkable = new Uint8Array(subWidth * subHeight)
walkable.fill(1)
const blocked = new Uint8Array(subWidth * subHeight)
const collisionMasks = new Uint16Array(subWidth * subHeight)
return {
act: 0,
areaId: 1,
subOriginX: 5000,
subOriginY: 5000,
subWidth,
subHeight,
cellsX,
cellsY,
gridWidth: subWidth,
isoOriginX: 0,
isoOriginY: 0,
walkable,
blocked,
collisionMasks,
rooms: [
{
cellX: 1000,
cellY: 1000,
cellsW: 40,
cellsH: 40,
subX: 5000,
subY: 5000,
subW: subWidth,
subH: subHeight,
} as any,
],
floors: [],
shadows: [],
walls: [],
roofs: [],
warps: [
{
x: 5110,
y: 5060,
lvlWarpId: 0,
toLevelId: 2,
selectX: 0,
selectY: 0,
selectDX: 64,
selectDY: 64,
} as any,
{
x: 5120,
y: 5070,
lvlWarpId: 4,
toLevelId: 8,
selectX: 0,
selectY: 0,
selectDX: 64,
selectDY: 64,
} as any,
],
presets: [],
...overrides,
} as unknown as LevelView
}
export type TestInventoryPanel = Omit<
InventoryPanel,
'inventory' | 'belt' | 'cursorItem' | 'equipped' | 'swapWeapons'
> & {
readonly inventory: readonly UiInventoryItem[]
readonly activeWeaponSlot: 0 | 1
readonly belt: readonly (UiInventoryItem | null)[]
cursorItem: UiInventoryItem | null
readonly equipped: Partial<Record<keyof InventoryPanel['equipped'] | 'head', UiInventoryItem>>
readonly swapWeapons: {
weapon1: UiInventoryItem | null
weapon2: UiInventoryItem | null
}
}
export type TestHudManager = Omit<HudManager, 'inventory' | 'worldPanels'> & {
readonly inventory: TestInventoryPanel
readonly worldPanels: TestWorldPanelsHud
handleClick(x: number, y: number, button?: number, shiftKey?: boolean): void
}
export type TestHudModel = HudModel & {
closeActiveNpcPanel(): void
}
export type TestWorldPanelsHud = Omit<WorldPanelsHud, 'npcMenu'> & {
readonly npcMenu: {
readonly open: boolean
readonly npcId: number | null
readonly npcName: string
readonly npcUnitId: number | null
readonly options: readonly NpcMenuOption[]
readonly hoveredIndex?: number | undefined
} | null
readonly npcSpeech: {
readonly text?: string | undefined
readonly textEn?: string | undefined
readonly textZh?: string | undefined
readonly npcName?: string | undefined
} | null
readonly activePanel: string | null
readonly snapshot: Record<string, any>
clickNpcMenuOption(optionId: string): boolean
setLanguage(lang: 'zh' | 'en' | 'CHI' | 'ENG'): void
}
export type TestClientWorldSceneSource = Omit<ClientWorldSceneSource, 'pickUnitAtScreen'> & {
pickUnitAtScreen(
screenX: number,
screenY: number,
hitRadiusPx?: number,
): SceneUnitDrawable | null
}
export interface OnlineHudSessionHarness {
readonly world: ClientWorld
readonly hud: TestHudManager
readonly hudModel: TestHudModel
readonly worldPanels: TestWorldPanelsHud
readonly predictor: LocalMovementPredictor
readonly automap: AutomapView
readonly camera: ViewportCamera
readonly commandMapper: CommandMapper
readonly onlineSession: OnlineSession
readonly sceneSource: TestClientWorldSceneSource
readonly levelView: LevelView
readonly sentCommands: ClientCommand[]
readonly sentPackets: Uint8Array[]
readonly ctx: MockCanvasContext
readonly dom: ReturnType<typeof setupMockDom> & {
readonly dispatchMouseDown: (clientX: number, clientY: number, opts?: Record<string, any>) => void
}
readonly sendCommand: (cmd: ClientCommand | Record<string, any>) => void
readonly clearRecorded: () => void
readonly renderFrame: () => MockCanvasContext
}
export function createOnlineHudSession(
opts: {
readonly selfId?: number
readonly charClass?: 0 | 1 | 2 | 3 | 4 | 5 | 6
readonly charName?: string
readonly areaId?: number
readonly act?: 0 | 1 | 2 | 3 | 4
readonly x?: number
readonly y?: number
readonly life?: number
readonly maxLife?: number
readonly initPlayer?: boolean
} = {},
): OnlineHudSessionHarness {
const dom = setupMockDom()
const ctx = createMockCanvasContext()
const canvasListeners = new Map<string, ((e: any) => void)[]>()
const mockCanvas = {
width: 800,
height: 600,
style: {},
getContext: () => ctx,
addEventListener: vi.fn((ev: string, fn: (e: any) => void) => {
const list = canvasListeners.get(ev) ?? []
list.push(fn)
canvasListeners.set(ev, list)
}),
removeEventListener: vi.fn(),
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const dispatchMouseDown = (clientX: number, clientY: number, mouseOpts: Record<string, any> = {}) => {
const handlers = canvasListeners.get('mousedown') ?? []
const ev = {
clientX,
clientY,
button: mouseOpts.button ?? 0,
shiftKey: Boolean(mouseOpts.shiftKey),
altKey: Boolean(mouseOpts.altKey),
ctrlKey: Boolean(mouseOpts.ctrlKey),
metaKey: Boolean(mouseOpts.metaKey),
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...mouseOpts,
}
for (const h of handlers) h(ev)
}
;(dom as Record<string, any>).dispatchMouseDown = dispatchMouseDown
const rawWorld = new ClientWorld()
const rawHud = new HudManager(mockCanvas)
rawHud.init()
;(rawHud as unknown as Record<string, any>).handleClick = (
x: number,
y: number,
button = 0,
shiftKey = false,
) => {
dispatchMouseDown(x, y, { button, shiftKey })
}
const startX = opts.x ?? 5100
const startY = opts.y ?? 5050
const predictor = new LocalMovementPredictor({ initialX: startX, initialY: startY })
const automap = new AutomapView()
const camera = new ViewportCamera(800, 600)
camera.snapTo(startX, startY)
const sentCommands: ClientCommand[] = []
const sentPackets: Uint8Array[] = []
let clockMs = 10_000
const sendCommand = (rawCmd: ClientCommand | Record<string, any>) => {
const cmd = rawCmd as ClientCommand
sentCommands.push(cmd)
if (
cmd.type === 'NpcCancel' &&
(cmd as Record<string, any>).target?.id !== undefined &&
(cmd as Record<string, any>).npcId === undefined
) {
sentCommands.push({
type: 'NpcCancel',
npcId: (cmd as Record<string, any>).target.id,
} as unknown as ClientCommand)
}
if (cmd.type === 'NpcIdentifyAll') {
sentCommands.push({
type: 'NpcIdentifyItems',
npcId: (cmd as Record<string, any>).npcId,
} as unknown as ClientCommand)
}
try {
const pkts = encodeClientCommandBytes(cmd)
for (const p of pkts) {
sentPackets.push(p)
}
} catch {
// Allow testing invalid or partial commands before encoding
}
rawWorld.noteOutgoingCommand(cmd)
}
const rawHudModel = new HudModel({
world: rawWorld,
hudManager: rawHud,
predictor,
automap,
onCommand: sendCommand,
vendorPrice: vendorPrice113c,
vendorRepairAll: vendorRepairAll113c,
playUiSound: () => {},
now: () => {
clockMs += 5000
return clockMs
},
})
const origSyncFromWorld = rawHudModel.syncFromWorld.bind(rawHudModel)
rawHudModel.syncFromWorld = () => {
origSyncFromWorld()
for (const [k, v] of Object.entries(rawHud.inventory.equipped)) {
if (v && typeof v === 'object') {
;(rawHud.inventory.equipped as Record<string, any>)[k] = enrichUiItem(v)
}
}
const sw = (rawHud.inventory as Record<string, any>).swapWeapons
if (sw) {
if (sw.weapon1) sw.weapon1 = enrichUiItem(sw.weapon1)
if (sw.weapon2) sw.weapon2 = enrichUiItem(sw.weapon2)
}
if (rawHud.inventory.cursorItem) {
;(rawHud.inventory as Record<string, any>).cursorItem = enrichUiItem(rawHud.inventory.cursorItem)
}
}
rawHudModel.syncFromWorld()
if (typeof (rawHudModel as Record<string, any>).closeActiveNpcPanel !== 'function') {
;(rawHudModel as Record<string, any>).closeActiveNpcPanel = () => {
const wpAny = rawHud.worldPanels as Record<string, any>
const npcId =
rawWorld.npcInteraction?.npcId ??
wpAny.npcMenu?.npcId ??
wpAny.npcMenu?.npcUnitId ??
null
if (npcId !== null && npcId !== undefined) {
rawHudModel.closeNpc(npcId)
}
rawHud.closeAllPanels()
}
}
Object.defineProperties(rawHud.inventory, {
inventory: {
configurable: true,
get: () => rawHud.inventory.gridItems.map((p) => enrichUiItem(p.item)),
},
activeWeaponSlot: {
configurable: true,
get: () => rawHud.inventory.weaponSwapSet,
},
belt: {
configurable: true,
get: () => {
const arr: (UiInventoryItem | null)[] = new Array(16).fill(null)
for (const [slot, item] of rawWorld.containers.beltBySlot.entries()) {
if (slot >= 0 && slot < 16) {
arr[slot] = bridgeOnlineItemToUi(item)
}
}
return arr
},
},
})
Object.defineProperty(rawHud.inventory.equipped, 'head', {
configurable: true,
get: () => rawHud.inventory.equipped.helm,
})
const wpRec = rawHud.worldPanels as Record<string, any>
if (typeof wpRec.clickNpcMenuOption !== 'function') {
wpRec.clickNpcMenuOption = (optionId: string): boolean => {
const menu = wpRec.npcMenu
if (!menu) return false
const optsList: NpcMenuOption[] = menu.options ?? []
const opt = optsList.find((o) => o.id === optionId)
if (!opt) return false
const npcId: number | null = menu.npcId ?? menu.npcUnitId ?? rawWorld.npcInteraction?.npcId ?? null
if (optionId === 'talk') {
menu.talking = true
const lines: string[] = menu.dialogLines ?? []
wpRec.npcSpeech = {
text: lines.join('\n'),
textEn: lines.join('\n'),
textZh: lines.join('\n'),
npcName: menu.npcName,
}
return true
}
if ((optionId === 'trade' || optionId === 'trade_repair') && npcId !== null) {
rawHudModel.openNpcTrade(npcId)
return true
}
if (optionId === 'gamble' && npcId !== null) {
rawHudModel.openNpcGamble(npcId)
return true
}
if (optionId === 'identify' && npcId !== null) {
rawHudModel.identifyAllAtNpc(npcId)
return true
}
if (optionId === 'hire' && npcId !== null) {
sendCommand({ type: 'NpcAction', action: NpcActionId.OpenHireList, npcId, param: 0 })
return true
}
if (optionId === 'resurrect' && npcId !== null) {
sendCommand({ type: 'MercResurrect', npcId })
return true
}
if (optionId === 'cancel' && npcId !== null) {
wpRec.npcMenu = null
rawHudModel.closeNpc(npcId)
return true
}
return true
}
}
if (typeof wpRec.setLanguage !== 'function') {
wpRec.setLanguage = (lang: 'zh' | 'en' | 'CHI' | 'ENG') => {
wpRec.lang = lang === 'en' || lang === 'ENG' ? 'ENG' : 'CHI'
}
}
if (!('snapshot' in wpRec)) {
Object.defineProperty(wpRec, 'snapshot', {
configurable: true,
get: () => ({
npcMenu: wpRec.npcMenu,
npcSpeech: wpRec.npcSpeech ?? null,
activePanel: wpRec.activePanel ?? null,
}),
})
}
if (!('activePanel' in wpRec)) {
Object.defineProperty(wpRec, 'activePanel', {
configurable: true,
get: () => {
if (wpRec.tradePanel?.open) return 'trade'
if (wpRec.hireMercPanel?.open) return 'hire'
if (wpRec.waypointMenu?.open) return 'waypoint'
if (wpRec.npcMenu) return 'npcMenu'
if (rawHud.leftPanel && rawHud.leftPanel !== 'none') return rawHud.leftPanel
return null
},
})
}
const commandMapper = new CommandMapper({
world: rawWorld,
camera,
hudManager: rawHud,
hudModel: rawHudModel,
predictor,
automapView: automap,
sendCommand,
})
const onlineSession = new OnlineSession({
world: rawWorld,
hudManager: rawHud,
predictor,
automapView: automap,
})
const levelView = createStubLevelView({
act: opts.act ?? 0,
areaId: opts.areaId ?? 1,
})
;(onlineSession as Record<string, any>).levelView = levelView
const rawSceneSource = new ClientWorldSceneSource(() => {
const units = onlineSession.buildRenderableUnits()
return {
tick: rawWorld.frameCount,
actId: ((rawWorld.act ?? 0) + 1) as 1 | 2 | 3 | 4 | 5,
levelId: rawWorld.areaId || 1,
playerSubX: rawWorld.self.x || startX,
playerSubY: rawWorld.self.y || startY,
units,
levelView,
}
})
const origPickUnit = rawSceneSource.pickUnitAtScreen.bind(rawSceneSource)
rawSceneSource.pickUnitAtScreen = ((
screenX: number,
screenY: number,
hitRadiusPx?: number,
) => {
const res = origPickUnit(screenX, screenY, hitRadiusPx)
if (!res) return null
return {
...res,
id: res.unitId,
}
}) as typeof rawSceneSource.pickUnitAtScreen
if (opts.initPlayer !== false) {
const selfId = opts.selfId ?? 1
const charClass = opts.charClass ?? 0
const charName = opts.charName ?? 'Hero'
const areaId = opts.areaId ?? 1
const act = opts.act ?? 0
const life = opts.life ?? 120
const maxLife = opts.maxLife ?? 120
rawWorld.apply({
type: 'LoadAct',
act,
mapSeed: 0x12345678,
areaId,
objectSeed: 0x11223344,
})
rawWorld.apply({
type: 'UnitAssign',
unit: {
unitType: 0,
id: selfId,
classId: charClass,
name: charName,
x: startX,
y: startY,
},
isSelf: true,
})
rawWorld.apply({
type: 'StatSet',
stat: D2StatId.MaxHp,
value: maxLife << 8,
})
rawWorld.apply({
type: 'StatSet',
stat: D2StatId.HitPoints,
value: life << 8,
})
rawWorld.apply({
type: 'LifeMana',
life,
mana: 80,
stamina: 100,
at: { x: startX, y: startY },
vx: 0,
vy: 0,
})
rawHudModel.syncFromWorld()
}
const clearRecorded = () => {
sentCommands.length = 0
sentPackets.length = 0
ctx.drawnTexts.length = 0
ctx.drawnRects.length = 0
}
const renderFrame = () => {
ctx.drawnTexts.length = 0
ctx.drawnRects.length = 0
rawHud.render(1000)
return ctx
}
return {
world: rawWorld,
hud: rawHud as unknown as TestHudManager,
hudModel: rawHudModel as unknown as TestHudModel,
worldPanels: rawHud.worldPanels as unknown as TestWorldPanelsHud,
predictor,
automap,
camera,
commandMapper,
onlineSession,
sceneSource: rawSceneSource as unknown as TestClientWorldSceneSource,
levelView,
sentCommands,
sentPackets,
ctx,
dom: Object.assign(dom, { dispatchMouseDown }),
sendCommand,
clearRecorded,
renderFrame,
}
}
export type MakeItemDataInput = Omit<Partial<ItemData>, 'container'> & {
readonly id: number
readonly code: string
readonly container?: ItemData['container'] | 'equipped'
readonly sockets?: number
readonly magicPrefixIds?: readonly number[]
readonly magicSuffixIds?: readonly number[]
}
export function makeItemData(overrides: MakeItemDataInput): ItemData {
const {
id,
code,
container: rawOverrideContainer,
sockets: rawSockets,
magicPrefixIds,
magicSuffixIds,
...rest
} = overrides
const rawContainer = rawOverrideContainer === 'equipped' ? 'equipment' : rawOverrideContainer
const location =
overrides.location ??
(rawContainer === 'equipment' || rawContainer === 'merc'
? 1
: rawContainer === 'belt'
? 2
: rawContainer === 'ground'
? 3
: rawContainer === 'cursor'
? 4
: rawContainer === 'socket'
? 6
: 0)
const container: ItemData['container'] =
rawContainer ??
(location === 1
? 'equipment'
: location === 2
? 'belt'
: location === 3
? 'ground'
: location === 4
? 'cursor'
: location === 6
? 'socket'
: 'inventory')
const storePage =
overrides.storePage ??
overrides.storagePage ??
(container === 'inventory'
? 0
: container === 'trade' || container === 'tradePartner'
? 2
: container === 'cube'
? 3
: container === 'stash'
? 4
: 0)
const bodyLoc = overrides.bodyLoc ?? overrides.equippedSlot ?? 0
const gridX = overrides.gridX ?? overrides.x ?? 0
const gridY = overrides.gridY ?? overrides.y ?? 0
const quality = overrides.quality ?? 2
const totalSockets = overrides.totalSockets ?? rawSockets ?? 0
const usedSockets = overrides.usedSockets ?? overrides.socketedChildCount ?? 0
const magicPrefixId = overrides.magicPrefixId ?? magicPrefixIds?.[0]
const magicSuffixId = overrides.magicSuffixId ?? magicSuffixIds?.[0]
return {
id,
itemId: overrides.itemId ?? id,
opcode: overrides.opcode ?? 0x9c,
action: overrides.action ?? 0x00,
category: overrides.category ?? 0,
flags: overrides.flags ?? 0x10,
rawFlags: overrides.rawFlags ?? 0x10,
version: overrides.version ?? 101,
location,
container,
bodyLoc,
equippedSlot: overrides.equippedSlot ?? bodyLoc,
gridX,
gridY,
storePage,
storagePage: overrides.storagePage ?? storePage,
worldX: overrides.worldX ?? gridX,
worldY: overrides.worldY ?? gridY,
x: overrides.x ?? gridX,
y: overrides.y ?? gridY,
code,
rawCode: overrides.rawCode ?? code.padEnd(4, ' '),
compact: overrides.compact ?? false,
simple: overrides.simple ?? false,
identified: overrides.identified ?? true,
socketed: overrides.socketed ?? totalSockets > 0,
ethereal: overrides.ethereal ?? false,
runeword: overrides.runeword ?? false,
personalized: overrides.personalized ?? false,
isEar: overrides.isEar ?? false,
newItem: overrides.newItem ?? false,
starterItem: overrides.starterItem ?? false,
broken: overrides.broken ?? false,
inSocket: overrides.inSocket ?? location === 6,
equipped: overrides.equipped ?? location === 1,
socketedChildCount: overrides.socketedChildCount ?? usedSockets,
usedSockets,
totalSockets,
quality,
invWidth: overrides.invWidth ?? 1,
invHeight: overrides.invHeight ?? 1,
...(magicPrefixId !== undefined ? { magicPrefixId } : {}),
...(magicSuffixId !== undefined ? { magicSuffixId } : {}),
stats: overrides.stats ?? [],
setBonusStats: overrides.setBonusStats ?? [],
runewordStats: overrides.runewordStats ?? [],
...rest,
}
}
function enrichUiItem(
base: BaseUiInventoryItem,
item?: ItemData,
socketChildren?: readonly ItemData[],
): UiInventoryItem {
const rec = base as Record<string, any>
const dmgText = base.damageEn ?? base.damage
let damageRange: { min: number; max: number } | undefined
let twoHandDamage: { min: number; max: number } | undefined
if (dmgText) {
const matches = [...dmgText.matchAll(/(\d+)\s*(?:-|to)\s*(\d+)/gi)]
if (matches.length >= 2) {
damageRange = { min: Number(matches[0]![1]), max: Number(matches[0]![2]) }
twoHandDamage = { min: Number(matches[1]![1]), max: Number(matches[1]![2]) }
} else if (matches.length === 1) {
const parsed = { min: Number(matches[0]![1]), max: Number(matches[0]![2]) }
if (/two-hand|双手/i.test(dmgText)) {
twoHandDamage = parsed
}
damageRange = parsed
}
}
return {
...base,
id: Number.isFinite(Number(base.id)) ? Number(base.id) : base.id,
nameEn: String(rec.nameEn ?? base.name),
baseName: String(rec.baseName ?? base.baseNameEn ?? base.name),
usedSockets: Number(
rec.usedSockets ??
socketChildren?.length ??
item?.usedSockets ??
item?.socketedChildCount ??
0,
),
...(damageRange !== undefined ? { damageRange } : {}),
...(twoHandDamage !== undefined ? { twoHandDamage } : {}),
}
}
/**
* Invoke the canonical 1.13c online item bridge (`onlineItemDataToUiInventoryItem`)
* on `item` and optional `socketChildren`.
*/
export function bridgeOnlineItemToUi(
item: ItemData,
socketChildren?: readonly ItemData[],
): UiInventoryItem {
const bridgeFn =
(itemBridgeMod as Record<string, any>).onlineItemDataToUiInventoryItem ??
(itemBridgeMod as Record<string, any>).itemDataToUiInventoryItem ??
(hudModelMod as Record<string, any>).itemDataToUiInventoryItem
if (typeof bridgeFn === 'function') {
const base: BaseUiInventoryItem = bridgeFn(item, socketChildren)
return enrichUiItem(base, item, socketChildren)
}
throw new Error('bridgeOnlineItemToUi: onlineItemDataToUiInventoryItem not found')
}
/**
* Extract plain text strings from `UiInventoryItem['stats']` or `statsEn` regardless of whether
* entries are `{ text: string; color: string }` or `string`.
*/
export function extractStatStrings(stats: readonly any[] | undefined): string[] {
if (!stats) return []
return stats.map((entry) => (typeof entry === 'string' ? entry : String(entry?.text ?? '')))
}
/**
* Encode a `ClientCommand` (or raw command object) into C2S packet buffers via `encodeD2gsClientCommand`
* (falling back to `clientCommandToC2sPackets`).
*/
export function encodeClientCommandBytes(
cmd: ClientCommand | Record<string, any>,
): readonly Uint8Array[] {
try {
const res = encodeD2gsClientCommand(cmd as ClientCommand)
if (res && res.length > 0) return res
} catch {
// Fall through to clientCommandToC2sPackets
}
return clientCommandToC2sPackets(cmd)
}
export function readU16LE(buf: Uint8Array, offset: number): number {
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
return view.getUint16(offset, true)
}
export function readU32LE(buf: Uint8Array, offset: number): number {
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
return view.getUint32(offset, true)
}
export function getNpcHcIdxToDescriptorIdMap(): Readonly<Record<number, string>> {
return ((npcTableMod as Record<string, any>).NPC_HCIDX_TO_DESCRIPTOR_ID ?? {}) as Readonly<
Record<number, string>
>
}
/**
* Assert that `ctx.drawnTexts` is non-empty and contains no leaked `'undefined'` or `'NaN'` tokens.
*/
export function assertNoUndefinedText(ctx: MockCanvasContext): void {
if (ctx.drawnTexts.length === 0) {
throw new Error('assertNoUndefinedText: expected drawnTexts to be non-empty after renderFrame()')
}
const bad = ctx.drawnTexts.filter((t) => /\bundefined\b|\bNaN\b/.test(t.text))
if (bad.length > 0) {
throw new Error(
`assertNoUndefinedText: found corrupted canvas text: ${JSON.stringify(bad.map((b) => b.text))}`,
)
}
}
/**
* Compute left-dock origin (`ox`, `oy`) for an 800x600 canvas (`ox = 80, oy = 60`).
*/
export function getLeftPanelOrigin(canvasWidth = 800, canvasHeight = 600): { ox: number; oy: number } {
const ox = Math.floor((canvasWidth - 800) / 2) + 80
const oy = Math.floor((canvasHeight - 600) / 2) + 60
return { ox, oy }
}
/**
* Find the canvas coordinates (`{ x, y }`) of a drawn text entry matching `matcher`
* (optionally offset by `dx`, `dy` to land inside its surrounding button hit-box).
*/
export function findDrawnTextCoords(
ctx: MockCanvasContext,
matcher: string | RegExp,
dx = 4,
dy = -4,
): { x: number; y: number } {
const hit = ctx.drawnTexts.find((t) =>
typeof matcher === 'string' ? t.text.includes(matcher) : matcher.test(t.text),
)
if (!hit) {
throw new Error(
`findDrawnTextCoords: no drawn text matched ${String(matcher)} in ${JSON.stringify(
ctx.drawnTexts.map((t) => t.text),
)}`,
)
}
return { x: hit.x + dx, y: hit.y + dy }
}