1420 lines
42 KiB
TypeScript
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 }
|
|
}
|
|
|