diablo2-web/tests/input-controls.test.ts

417 lines
15 KiB
TypeScript

/**
* Unit & Parity Tests for Keyboard Conflict Resolution & Hotkey Harmonization (Requirement R1 / R5)
* Verifies ghost interaction decoupling, combat safety, and potion drinking invariants.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts'
import { seedDemoHud } from './fixtures/hud-demo-state.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
import { WaypointNetwork } from '../src/common/world/portal.ts'
import type { SceneEntrance, SceneWarp } from '../src/common/world/level-links.ts'
import { subTileToScene } from '../src/common/world/d2map.ts'
import { getLevelNames } from '../src/common/world/level-names-zh.ts'
import { screenToWorld, facingOf, TELEKINESIS_RANGE_PX, getWarpLabelWorldPos, getEntranceLabelWorldPos, hitTestWarp, hitTestEntrance, isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile } from '../src/common/world/scene-links-nav.ts'
import { PASSIVE_SKILL_IDS, isPassiveSkill, getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.ts'
class MockWindow extends EventTarget {
innerWidth = 1920
innerHeight = 1080
devicePixelRatio = 1
}
class MockCanvas extends EventTarget {
clientWidth = 1920
clientHeight = 1080
width = 1920
height = 1080
style = { pointerEvents: 'none', cursor: '' }
getBoundingClientRect() {
return { left: 0, top: 0, width: 1920, height: 1080 }
}
getContext() {
return null
}
}
class SyntheticKeyboardEvent extends Event {
readonly code: string
readonly key: string
constructor(type: string, init: { code: string; key: string }) {
super(type, { cancelable: true })
this.code = init.code
this.key = init.key
}
}
class SyntheticPointerEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly buttons: number
readonly shiftKey: boolean
private _defaultPrevented = false
constructor(type: string, init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean; cancelable?: boolean }) {
super(type, { cancelable: init.cancelable ?? true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.buttons = init.buttons !== undefined ? init.buttons : (1 << (init.button ?? 0))
this.shiftKey = init.shiftKey ?? false
}
override get defaultPrevented(): boolean {
return this._defaultPrevented
}
override preventDefault() {
super.preventDefault()
this._defaultPrevented = true
}
}
// Global DOM shims for HudManager
const mockWin = new MockWindow()
// @ts-ignore
globalThis.window = mockWin
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
describe('Milestone 1: Keyboard Conflict Resolution & Hotkey Harmonization (R1)', () => {
it('harmonizes Space with Escape to close open HUD panels', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
// Character Sheet toggle and Space dismiss
hud.toggleLeftPanel('char')
expect(hud.leftPanel).toBe('char')
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(hud.leftPanel).toBe('none')
// Skill Tree toggle and Space dismiss
hud.toggleRightPanel('skill')
expect(hud.rightPanel).toBe('skill')
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(hud.rightPanel).toBe('none')
// Inventory toggle and Space dismiss
hud.toggleRightPanel('inv')
expect(hud.rightPanel).toBe('inv')
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(hud.rightPanel).toBe('none')
})
it('requests belt potion use for columns 0..3 via keys 1..4 (server-authoritative) without resetting active skills', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
// Explicit legacy demo state: Sorceress, 16-potion belt, vitals
seedDemoHud(hud)
hud.hotkeys.leftSkillId = 47 // Fire Ball
hud.hotkeys.rightSkillId = 64 // Frozen Orb
hud.syncPublishedState()
hud.hp = 400
hud.mana = 200
expect(hud.belt.countTotalPotions()).toBe(16)
// No engine and no command sink: keys 1..4 do nothing locally (no client-side healing / belt mutation)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
expect(hud.hp).toBe(400)
expect(hud.belt.countTotalPotions()).toBe(16)
// With a server command sink attached, keys 1..4 only emit BeltUse requests for columns 0..3
const beltRequests: number[] = []
hud.commandSink = {
useBeltSlot: (col: number) => {
beltRequests.push(col)
return true
},
} as unknown as HudCommandSink
for (const [i, key] of ['1', '2', '3', '4'].entries()) {
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: `Digit${key}`, key }))
expect(beltRequests).toEqual([0, 1, 2, 3].slice(0, i + 1))
// Active skills are never reset by belt hotkeys
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.state.leftSkillId).toBe(47)
expect(hud.state.rightSkillId).toBe(64)
}
// Local state is unchanged until the server answers (HudModel.syncFromWorld)
expect(hud.hp).toBe(400)
expect(hud.mana).toBe(200)
expect(hud.belt.countTotalPotions()).toBe(16)
hud.commandSink = null
})
})
describe('Milestone 2: Mouse Interaction, Click-to-Move, and Combat Controls (R2, R3, R4)', () => {
it('projects screen coordinates to world coordinates through inverse camera transform', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const rect = canvas.getBoundingClientRect()
const camX = 500
const camY = 484 // player.y - 16 = 500 - 16
// Center of screen (960, 540) maps exactly to (camX, camY) = (500, 484)
const center = screenToWorld({
clientX: 960,
clientY: 540,
rect,
canvas,
camX,
camY,
cameraZoom: 1,
})
expect(center.x).toBe(500)
expect(center.y).toBe(484)
// Offset +100px X, +50px Y at zoom 1
const offset = screenToWorld({
clientX: 1060,
clientY: 590,
rect,
canvas,
camX,
camY,
cameraZoom: 1,
})
expect(offset.x).toBe(600)
expect(offset.y).toBe(534)
// Offset at zoom 2
const offsetZoom2 = screenToWorld({
clientX: 1060,
clientY: 590,
rect,
canvas,
camX,
camY,
cameraZoom: 2,
})
expect(offsetZoom2.x).toBe(550)
expect(offsetZoom2.y).toBe(509)
})
it('computes 8-direction facing index with 0=South turning clockwise (D2 convention)', () => {
// 0 = South (dx=0, dy>0)
expect(facingOf(0, 1)).toBe(0)
// 1 = South-West (dx<0, dy>0)
expect(facingOf(-1, 1)).toBe(1)
// 2 = West (dx<0, dy=0)
expect(facingOf(-1, 0)).toBe(2)
// 3 = North-West (dx<0, dy<0)
expect(facingOf(-1, -1)).toBe(3)
// 4 = North (dx=0, dy<0)
expect(facingOf(0, -1)).toBe(4)
// 5 = North-East (dx>0, dy<0)
expect(facingOf(1, -1)).toBe(5)
// 6 = East (dx>0, dy=0)
expect(facingOf(1, 0)).toBe(6)
// 7 = South-East (dx>0, dy>0)
expect(facingOf(1, 1)).toBe(7)
})
})
describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Labels', () => {
it('verifies all 136 baked map packs (192 SceneWarp and 60 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => {
const baseDir = path.resolve(process.cwd(), 'samples/d2-packs')
const indexPath = path.join(baseDir, 'index.json')
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
const packs: Array<{ path: string }> = index.levels ?? []
expect(packs.length).toBe(136)
let warpCount = 0
let nonZeroBoxWarps = 0
let zeroBoxWarps = 0
let entranceCount = 0
for (const entry of packs) {
const pack = JSON.parse(fs.readFileSync(path.join(baseDir, entry.path, 'scene.json'), 'utf8'))
const cellsX: number = pack.cellsX ?? 40
const cellsY: number = pack.cellsY ?? 40
const grid = {
originX: Math.ceil((cellsX + cellsY) * 80 * 0.5),
originY: 64,
}
for (const warp of (pack.warps ?? []) as SceneWarp[]) {
warpCount += 1
if (warp.selectDX > 0 && warp.selectDY > 0) nonZeroBoxWarps += 1
else zeroBoxWarps += 1
expect(warp.toLevelId).toBeGreaterThan(0)
const nameZh = getLevelNames(warp.toLevelId).nameZh
expect(nameZh.length).toBeGreaterThan(0)
const labelPos = getWarpLabelWorldPos(warp, grid)
expect(Number.isFinite(labelPos.x)).toBe(true)
expect(Number.isFinite(labelPos.y)).toBe(true)
const hitLabel = hitTestWarp(warp, labelPos.x, labelPos.y - 12, grid)
expect(hitLabel.hit).toBe(true)
const hitArrive = hitTestWarp(warp, hitLabel.arrivePos.x, hitLabel.arrivePos.y, grid)
expect(hitArrive.hit).toBe(true)
expect(isPlayerWithinLinkRange(hitLabel.arrivePos.x, hitLabel.arrivePos.y, warp, grid)).toBe(true)
}
for (const entrance of (pack.entrances ?? []) as SceneEntrance[]) {
entranceCount += 1
expect(entrance.toLevelId).toBeGreaterThan(0)
const nameZh = getLevelNames(entrance.toLevelId).nameZh
expect(nameZh.length).toBeGreaterThan(0)
const labelPos = getEntranceLabelWorldPos(entrance, grid)
expect(Number.isFinite(labelPos.x)).toBe(true)
expect(Number.isFinite(labelPos.y)).toBe(true)
const hitLabel = hitTestEntrance(entrance, labelPos.x, labelPos.y - 12, grid)
expect(hitLabel.hit).toBe(true)
const hitArrive = hitTestEntrance(entrance, hitLabel.arrivePos.x, hitLabel.arrivePos.y, grid)
expect(hitArrive.hit).toBe(true)
expect(isPlayerWithinLinkRange(hitLabel.arrivePos.x, hitLabel.arrivePos.y, entrance, grid)).toBe(true)
}
}
// 192 total warps (179 with LvlWarp.txt bounding boxes + 13 zero-box warps)
// across the 136 canonical DRLG scenes (DRLG_ACT_VARIANTS = 1, GAME_SEED = 0x5eed_0100).
expect(warpCount).toBe(192)
expect(nonZeroBoxWarps).toBe(179)
expect(zeroBoxWarps).toBe(13)
// 60 seamless entrances across the 136 canonical DRLG scenes (62 seamless edges in
// world-graph.json minus the 2 mutually-exclusive inactive Act 3 Jungle seam edges).
expect(entranceCount).toBe(60)
})
it('handles non-unit collision bitmask bytes (e.g. 0x21) and routes around walls when target is inside a 3x3 blocked footprint', () => {
const gridW = 100
const blocked = new Uint8Array(gridW * gridW)
const grid = {
originX: 0,
originY: 0,
cellsX: 20,
cellsY: 20,
gridWidth: gridW,
blocked,
}
// Place a horizontal wall at y = 25, x = 15..25 using non-unit collision bitmask 0x21 (COLLIDE_BLANK | COLLIDE_WALL)
for (let x = 15; x <= 25; x++) {
blocked[25 * gridW + x] = 0x21
}
// Enclose the target (20, 30) in a 3x3 blocked object footprint (e.g. stash/chest/monster)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
blocked[(30 + dy) * gridW + (20 + dx)] = 0x04
}
}
const startPos = subTileToScene(20, 20, grid)
const targetPos = subTileToScene(20, 30, grid)
const steer = resolveObstacleAwareSteerTarget(startPos.x, startPos.y, targetPos, grid)
// Direct line from (20,20) to (20,30) crosses the 0x21 wall at y=25;
// A* must route around the wall rather than returning targetPos directly.
expect(steer).not.toEqual(targetPos)
const steerSub = sceneToSubTile(steer.x, steer.y, grid)
expect(blocked[steerSub.y * gridW + steerSub.x]).toBe(0)
})
it('resolves multi-warp and warp-vs-entrance overlaps by true Euclidean distance and prioritizes foreground overhead badges over background LvlWarp.txt boxes', () => {
const grid = { originX: 0, originY: 0 }
// Warp A has a huge LvlWarp.txt selection box centered at (30, 30)
const warpA: SceneWarp = {
source: 'tile',
direction: 'down',
label: 'Harem Level 1',
toLevelId: 50,
warpId: 23,
x: 30,
y: 30,
arriveX: 31,
arriveY: 30,
exitWalkX: 31,
exitWalkY: 30,
selectX: -120,
selectY: -120,
selectDX: 240,
selectDY: 240,
}
// Warp B is a zero-box warp at (33, 30) (48px right of Warp A)
const warpB: SceneWarp = {
source: 'tile',
direction: 'up',
label: 'Lut Gholein',
toLevelId: 40,
warpId: 22,
x: 33,
y: 30,
arriveX: 34,
arriveY: 30,
exitWalkX: 34,
exitWalkY: 30,
selectX: 0,
selectY: 0,
selectDX: 0,
selectDY: 0,
}
// Entrance C is at (27, 30) (48px left of Warp A)
const entranceC: SceneEntrance = {
toLevelId: 41,
side: 'west',
label: 'Rocky Waste',
x: 27,
y: 30,
arriveX: 28,
arriveY: 30,
}
const posB = subTileToScene(warpB.x, warpB.y, grid)
const posC = subTileToScene(entranceC.x, entranceC.y, grid)
// Clicking 16px from Warp B's anchor (still inside Warp A's [-120..120] box, 32px from A's center)
// must select Warp B because 16px < 32px.
const hitAatB = hitTestWarp(warpA, posB.x - 16, posB.y, grid)
const hitBatB = hitTestWarp(warpB, posB.x - 16, posB.y, grid)
expect(hitAatB.hit).toBe(true)
expect(hitBatB.hit).toBe(true)
expect(hitBatB.dist).toBeLessThan(hitAatB.dist)
// Clicking 16px from Entrance C's anchor (inside Warp A's box, 32px from A's center)
// must select Entrance C because 16px < 32px.
const hitAatC = hitTestWarp(warpA, posC.x + 16, posC.y, grid)
const hitCatC = hitTestEntrance(entranceC, posC.x + 16, posC.y, grid)
expect(hitAatC.hit).toBe(true)
expect(hitCatC.hit).toBe(true)
expect(hitCatC.dist).toBeLessThan(hitAatC.dist)
// Clicking directly on Warp B's overhead badge (which overlaps Warp A's LvlWarp.txt box)
// must prioritize the foreground overhead badge of Warp B over Warp A's background box.
const badgeB = getWarpLabelWorldPos(warpB, grid)
const hitAonBadgeB = hitTestWarp(warpA, badgeB.x + 20, badgeB.y - 12, grid)
const hitBonBadgeB = hitTestWarp(warpB, badgeB.x + 20, badgeB.y - 12, grid)
expect(hitAonBadgeB.hit).toBe(true)
expect(hitBonBadgeB.hit).toBe(true)
expect(hitBonBadgeB.dist).toBeLessThan(hitAonBadgeB.dist)
})
})