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

976 lines
31 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 { describe, expect, it } from 'vitest'
import { KeyboardInput } from '../src/sim/input.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts'
import { ViewportCamera } from '../src/sim/camera.ts'
import { WaypointNetwork } from '../src/game/portal.ts'
import {
SceneMouseController,
screenToWorld,
facingOf,
PASSIVE_SKILL_IDS,
isPassiveSkill,
getSkillRange,
getSkillMana,
type MapRuntime,
} from '../src/scene/act-scene.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' }
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('decouples KeyT from input.talking to eliminate ghost NPC interactions', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.talking).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyT', key: 't' }))
expect(input.talking).toBe(false)
// Held across multiple frames/ticks
for (let i = 0; i < 25; i++) {
expect(input.talking).toBe(false)
}
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyT', key: 't' }))
expect(input.talking).toBe(false)
})
it('decouples KeyB from input.takeWaypoint() to eliminate ghost waypoint travel', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.takeWaypoint()).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyB', key: 'b' }))
expect(input.takeWaypoint()).toBe(false)
for (let i = 0; i < 25; i++) {
expect(input.takeWaypoint()).toBe(false)
}
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyB', key: 'b' }))
expect(input.takeWaypoint()).toBe(false)
})
it('removes Space from ATTACK_KEYS so Space never triggers combat swings or skill casts', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
expect(input.attacking).toBe(false)
// Legitimate attack keys still work
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyJ', key: 'j' }))
expect(input.attacking).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyJ', key: 'j' }))
expect(input.attacking).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Enter', key: 'Enter' }))
expect(input.attacking).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'Enter', key: 'Enter' }))
expect(input.attacking).toBe(false)
})
it('verifies Space in GameEngine does not damage monsters or trigger combat hits', () => {
const dummyTerrain: WorldMapProvider = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [{ id: 'mon1', name: 'Zombie', hp: 100, damage: 0, cooldownTicks: 100, reach: 20, aggroRadius: 100, speed: 0, xp: 5 }],
xpTable: [0, 0, 10, 30, 60],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }],
npcDefs: [],
questDefs: [],
combatOptions: { playerSpeed: 100, playerReach: 50, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 },
lootSeed: 12345,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.monsters[0]!.x = 110
engine.world.monsters[0]!.y = 100
const startHp = engine.world.monsters[0]!.hp
const startHits = engine.metrics.playerHits
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Space', key: ' ' }))
for (let i = 0; i < 30; i++) {
engine.tick({
movement: input.movement(),
attacking: input.attacking,
pickingUp: input.pickingUp,
talking: input.talking,
digits: input.digits,
saving: input.saving,
loading: input.loading,
})
}
expect(engine.world.monsters[0]!.hp).toBe(startHp)
expect(engine.metrics.playerHits).toBe(startHits)
})
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('drinks belt potions from columns 0..3 via keys 1..4 without resetting active skills', () => {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud['bindEvents']()
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)
// Press '1' -> drinks column 0 (hp potion, healHp 180)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
expect(hud.hp).toBe(400 + 180)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.state.leftSkillId).toBe(47)
expect(hud.state.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(15)
// Press '2' -> drinks column 1 (hp potion, healHp 320)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit2', key: '2' }))
expect(hud.hp).toBe(580 + 320)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(14)
// Press '3' -> drinks column 2 (mana potion, healMana 150)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit3', key: '3' }))
expect(hud.mana).toBe(200 + 150)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(13)
// Press '4' -> drinks column 3 (rejuv potion, healHp 400, healMana 400)
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit4', key: '4' }))
expect(hud.hp).toBe(900 + 400 > hud.maxHp ? hud.maxHp : 1300)
expect(hud.mana).toBe(350 + 400)
expect(hud.hotkeys.leftSkillId).toBe(47)
expect(hud.hotkeys.rightSkillId).toBe(64)
expect(hud.belt.countTotalPotions()).toBe(12)
})
it('isolates WASD keys from movement Contribution', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
for (const code of ['KeyW', 'KeyA', 'KeyS', 'KeyD']) {
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code, key: code.slice(-1).toLowerCase() }))
expect(input.movement()).toEqual({ x: 0, y: 0 })
expect(input.active).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code, key: code.slice(-1).toLowerCase() }))
}
// Arrow keys still move
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ArrowUp', key: 'ArrowUp' }))
expect(input.movement()).toEqual({ x: 0, y: -1 })
expect(input.active).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ArrowUp', key: 'ArrowUp' }))
expect(input.active).toBe(false)
})
it('tracks shiftHeld state for force stand still attack', () => {
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
expect(input.shiftHeld).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
expect(input.shiftHeld).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ShiftLeft', key: 'Shift' }))
expect(input.shiftHeld).toBe(false)
win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' }))
expect(input.shiftHeld).toBe(true)
win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'ShiftRight', key: 'Shift' }))
expect(input.shiftHeld).toBe(false)
})
})
function createTestScene(options?: {
playerPos?: { x: number; y: number }
mana?: number
leftSkillId?: number
rightSkillId?: number
npcs?: Array<{ id: number; name: string; x: number; y: number }>
waypoints?: Array<{ waypointId: number; x: number; y: number }>
monsters?: Array<{ id: string; name: string; x: number; y: number; hp: number }>
blockedOverlap?: (x: number, y: number) => number
}) {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: options?.blockedOverlap ?? (() => 0),
}
const dummyOpts: GameEngineOptions = {
spawn: options?.playerPos ?? { x: 500, y: 500 },
stats: (options?.monsters ?? []).map(m => ({
id: m.id,
name: m.name,
hp: m.hp,
damage: 10,
cooldownTicks: 100,
reach: 20,
aggroRadius: 100,
speed: 0,
xp: 10,
})),
xpTable: [0, 0, 10, 30, 60],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 1, radius: 20 }],
npcDefs: (options?.npcs ?? []).map(n => ({
id: `npc-${n.id}`,
name: n.name,
questId: null,
offerLines: ['Greetings, wanderer.'],
progressLines: [],
doneLines: [],
})),
questDefs: [],
combatOptions: { playerSpeed: 100, playerReach: 52, playerCooldownTicks: 1, playerDamage: 10, playerManaPerAttack: 0, respawnTicks: 100 },
lootSeed: 12345,
talkRadius: 80,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
if (options?.monsters) {
for (let i = 0; i < options.monsters.length; i++) {
engine.world.monsters[i]!.x = options.monsters[i]!.x
engine.world.monsters[i]!.y = options.monsters[i]!.y
}
}
if (options?.npcs) {
for (const n of options.npcs) {
engine.npcEntities.push({
def: {
id: `npc-${n.id}`,
name: n.name,
questId: null,
offerLines: ['Greetings, wanderer.'],
progressLines: [],
doneLines: [],
},
x: n.x,
y: n.y,
hasSprite: false,
})
}
}
const camera = new ViewportCamera(canvas, 2000, 2000)
camera.zoom = 1
const hudManager = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hudManager.mana = options?.mana ?? 200
hudManager.hotkeys.leftSkillId = options?.leftSkillId ?? 0
hudManager.hotkeys.rightSkillId = options?.rightSkillId ?? 0
hudManager.syncPublishedState()
const waypointNetwork = new WaypointNetwork()
if (options?.waypoints) {
for (const wp of options.waypoints) {
waypointNetwork.register({
waypointId: wp.waypointId,
levelId: 1,
act: 1,
name: 'Test WP',
x: wp.x,
y: wp.y,
})
}
}
const gridWidth = 500
const blockedGrid = new Uint8Array(500 * 500)
const runtime: MapRuntime = {
source: 'pack',
base: 'samples/d2-packs',
act: 1,
level: 'Rogue Encampment',
levelId: 1,
variant: 'default',
variants: ['default'],
quadrant: 'default',
quadrants: ['default'],
cellsX: 100,
cellsY: 100,
widthPx: 2000,
heightPx: 2000,
floors: [],
walls: [],
roofs: [],
objectDrawables: [],
pages: [],
priorityPages: [],
objectPages: [],
grid: {
originX: 0,
originY: 0,
cellsX: 100,
cellsY: 100,
blocked: blockedGrid,
gridWidth,
},
npcs: (options?.npcs ?? []).map(n => ({
id: n.id,
type: 1,
name: n.name,
x: n.x,
y: n.y,
})),
entrances: [],
warps: [],
waypoints: (options?.waypoints ?? []).map(wp => {
const dx = wp.x / 16
const dy = wp.y / 8
const subX = Math.round((dy + dx) / 2)
const subY = Math.round((dy - dx) / 2)
return {
source: 'placed' as const,
waypointId: wp.waypointId,
arriveX: subX,
arriveY: subY,
x: subX,
y: subY,
}
}),
animatedDrawables: [],
animSpeed: 1,
frameDurationMs: 40,
palette: null,
charBases: [],
monsterTypes: [],
monsterPacks: [],
entryPath: '',
loadPages: async () => {},
} as unknown as MapRuntime
const status = { textContent: '' } as HTMLElement
const controller = new SceneMouseController({
canvas,
engine,
camera,
input,
getRuntime: () => runtime,
hudManager,
waypointNetwork,
status,
})
controller.attach()
return {
canvas,
win,
input,
engine,
camera,
hudManager,
waypointNetwork,
runtime,
status,
controller,
}
}
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)
})
it('R3: left-click on ground sets navTarget and generates movement towards target, stopping within 8px', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
// Click ground at +100px X, +0px Y (clientX = 960 + 100, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
}))
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: 1060,
clientY: 540,
button: 0,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 })
// First tick produces movement vector pointing right towards (600, 484)
const tick1 = scene.controller.tick()
expect(tick1.movement.x).toBeGreaterThan(0)
// Move player closer to target (598, 484) - distance is 2px (< 8px arrival threshold)
scene.engine.world.player.x = 598
scene.engine.world.player.y = 484
const arrivalTick = scene.controller.tick()
expect(arrivalTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.controller.navTarget).toBeNull()
})
it('R3: continuous left-click drag updates navTarget with cursor coordinates', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
buttons: 1,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 })
// Drag pointer to (1060, 640) -> world (600, 584)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
clientX: 1060,
clientY: 640,
button: 0,
buttons: 1,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 584 })
// pointerup releases hold
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
clientX: 1060,
clientY: 640,
button: 0,
buttons: 0,
}))
expect(scene.controller.isLeftMouseDown).toBe(false)
})
it('R4: left-click with Shift held executes force stand still attack without pathing', () => {
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
leftSkillId: 47, // Fire Ball
mana: 100,
})
// Hold Shift via window keydown
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
expect(scene.input.shiftHeld).toBe(true)
// Left-click towards (600, 484)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
shiftKey: true,
}))
// Movement must be strictly 0 (no pathing / stand still)
const tickResult = scene.controller.tick()
expect(tickResult.movement).toEqual({ x: 0, y: 0 })
expect(scene.controller.navTarget).toBeNull()
// Player faces cursor (East = 6)
expect(scene.engine.world.player.facing).toBe(6)
// Projectile spawned towards cursor
expect(scene.engine.projectiles.length).toBe(1)
expect(scene.engine.projectiles[0]!.skillId).toBe('47')
expect(scene.engine.projectiles[0]!.vx).toBeGreaterThan(0)
// Mana deducted (Fire Ball lv 8 costs 8.5)
expect(scene.hudManager.mana).toBe(100 - 8.5)
})
it('R3: left-click on NPC talks immediately if <= 80px, or paths and talks on arrival if > 80px', () => {
// NPC 1 is nearby (530, 500), distance 30px <= 80px
// NPC 2 is far away (750, 500), distance 250px > 80px
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
npcs: [
{ id: 1, name: 'Akara', x: 530, y: 500 },
{ id: 2, name: 'Kashya', x: 750, y: 500 },
],
})
// 1. Click nearby NPC Akara (world 530, 500 -> clientX = 960 + 30 = 990, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 990,
clientY: 540,
button: 0,
}))
// Talks immediately without pathing
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.pendingInteraction).toBeNull()
expect(scene.status.textContent).toContain('Akara')
expect(scene.engine.world.player.facing).toBe(6) // Facing East towards Akara
// 2. Click far NPC Kashya (world 750, 500 -> clientX = 960 + 250 = 1210, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1210,
clientY: 540,
button: 0,
}))
// Sets pending interaction and navTarget towards Kashya
expect(scene.controller.pendingInteraction).toEqual({
kind: 'npc',
npc: expect.objectContaining({ x: 750, y: 500 }),
})
expect(scene.controller.navTarget).toEqual({ x: 750, y: 500 })
// Walks towards Kashya
const walkTick = scene.controller.tick()
expect(walkTick.movement.x).toBeGreaterThan(0)
// Player arrives within 80px (e.g. x = 680, dist = 70px)
scene.engine.world.player.x = 680
const arriveTick = scene.controller.tick()
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.controller.pendingInteraction).toBeNull()
expect(scene.status.textContent).toContain('Kashya')
})
it('R3: left-click on Waypoint activates and opens menu if <= 80px, or paths and opens on arrival', () => {
// WP 1 nearby at (540, 500), dist 40px <= 80px
// WP 2 far away at (800, 500), dist 300px > 80px
// Note: waypoint subTile to scene is handled in test mock
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
waypoints: [
{ waypointId: 1, x: 540, y: 500 },
{ waypointId: 2, x: 800, y: 500 },
],
})
// Click nearby WP 1 (clientX = 960 + 40 = 1000, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1000,
clientY: 540,
button: 0,
}))
// Activated immediately and opened waypoint menu
expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 1)).toBe(true)
expect(scene.hudManager.leftPanel).toBe('waypoint')
expect(scene.controller.navTarget).toBeNull()
// Close waypoint panel
scene.hudManager.toggleLeftPanel('waypoint')
expect(scene.hudManager.leftPanel).toBe('none')
// Click far WP 2 (clientX = 960 + 300 = 1260, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1260,
clientY: 540,
button: 0,
}))
expect(scene.controller.pendingInteraction?.kind).toBe('waypoint')
expect(scene.controller.navTarget).toBeDefined()
// Player moves within 80px (e.g. x = 740, dist = 60px)
scene.engine.world.player.x = 740
const arriveTick = scene.controller.tick()
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.hudManager.leftPanel).toBe('waypoint')
expect(scene.controller.pendingInteraction).toBeNull()
})
it('R3: left-click on Monster attacks melee if <= 52px, or paths to monster and attacks on arrival', () => {
// Monster 1 nearby at (530, 500), dist 30px <= 52px
// Monster 2 far away at (700, 500), dist 200px > 52px
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
leftSkillId: 0, // Melee attack
monsters: [
{ id: 'mon1', name: 'Fallen', x: 530, y: 500, hp: 50 },
{ id: 'mon2', name: 'Zombie', x: 700, y: 500, hp: 100 },
],
})
// Click nearby monster 1 (clientX = 960 + 30 = 990, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 990,
clientY: 540,
button: 0,
}))
// Attacks immediately: deals damage, no pathing
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(50)
expect(scene.engine.metrics.playerHits).toBe(1)
expect(scene.controller.navTarget).toBeNull()
// Click far monster 2 (clientX = 960 + 200 = 1160, clientY = 540)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1160,
clientY: 540,
button: 0,
}))
expect(scene.controller.pendingInteraction?.kind).toBe('monster')
expect(scene.controller.navTarget).toEqual({ x: 700, y: 500 })
// Move player within reach (e.g. x = 660, dist = 40px <= 52px)
scene.engine.world.player.x = 660
scene.engine.world.player.cooldown = 0
const attackTick = scene.controller.tick()
expect(attackTick.movement).toEqual({ x: 0, y: 0 })
expect(scene.engine.world.monsters[1]!.hp).toBeLessThan(100)
expect(scene.engine.metrics.playerHits).toBe(2)
})
it('R2: right-click active skill checks mana, deducts mana, enforces cooldown, and casts', () => {
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 47, // Fire Ball, cost 8.5
mana: 100,
})
// 1. Sufficient mana
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene.hudManager.mana).toBe(100 - 8.5)
expect(scene.engine.projectiles.length).toBe(1)
expect(scene.engine.world.player.cooldown).toBeGreaterThan(0)
expect(scene.engine.world.player.facing).toBe(6) // Facing East
// 2. Insufficient mana: reduce mana to 5
scene.hudManager.mana = 5
scene.engine.world.player.cooldown = 0
scene.engine.projectiles.length = 0
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
// Must not move player, must display "法力不足!", must not spawn projectile
expect(scene.controller.navTarget).toBeNull()
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
expect(scene.status.textContent).toBe('法力不足!')
expect(scene.engine.projectiles.length).toBe(0)
expect(scene.hudManager.mana).toBe(5) // Unducted
})
it('R2: right-click Teleport (54) verifies target walkability and relocates player', () => {
const scene = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 54, // Teleport, cost 21
mana: 100,
blockedOverlap: (x, y) => (x > 1500 ? 1 : 0),
})
// Teleport to walkable location (600, 484)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene.engine.world.player.x).toBe(600)
expect(scene.engine.world.player.y).toBe(484)
expect(scene.hudManager.mana).toBe(100 - 21)
expect(scene.status.textContent).toBe('已施展传送')
// Teleport to blocked location (1600, 484) -> clientX = 960 + 1000 = 1960
scene.engine.world.player.cooldown = 0
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1960,
clientY: 540,
button: 2,
}))
// Does not move player
expect(scene.engine.world.player.x).toBe(600)
expect(scene.status.textContent).toBe('无法传送到该位置')
})
it('R2: right-click unassigned (0) or passive skill (37, 61) routes to walk/run towards cursor', () => {
// 1. Unassigned skill 0
const scene1 = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 0,
})
scene1.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene1.controller.navTarget).toEqual({ x: 600, y: 484 })
expect(scene1.controller.tick().movement.x).toBeGreaterThan(0)
// 2. Passive skill Warmth (37)
const scene2 = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 37,
})
scene2.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene2.controller.navTarget).toEqual({ x: 600, y: 484 })
expect(scene2.controller.tick().movement.x).toBeGreaterThan(0)
// 3. Passive skill Fire Mastery (61)
const scene3 = createTestScene({
playerPos: { x: 500, y: 500 },
rightSkillId: 61,
})
scene3.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 2,
}))
expect(scene3.controller.navTarget).toEqual({ x: 600, y: 484 })
expect(scene3.controller.tick().movement.x).toBeGreaterThan(0)
})
it('prevents contextmenu default on canvas and respects HUD interception', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
// Test contextmenu preventDefault
const contextMenuEvent = new SyntheticPointerEvent('contextmenu', {
clientX: 500,
clientY: 500,
})
scene.canvas.dispatchEvent(contextMenuEvent)
expect(contextMenuEvent.defaultPrevented).toBe(true)
// Test HUD interception at bottom control bar (clientY = 1000 -> logical.y = 555.5 >= 546)
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960,
clientY: 1000,
button: 0,
}))
expect(scene.controller.navTarget).toBeNull()
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 960,
clientY: 1000,
button: 2,
}))
expect(scene.controller.navTarget).toBeNull()
})
it('clears navTarget on stuck detection after 12 ticks without moving', () => {
const scene = createTestScene({ playerPos: { x: 500, y: 500 } })
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
clientX: 1060,
clientY: 540,
button: 0,
}))
expect(scene.controller.navTarget).toEqual({ x: 600, y: 484 })
// Simulate player being stuck at (500, 500) for 12 ticks
for (let i = 0; i < 11; i++) {
scene.controller.tick()
expect(scene.controller.navTarget).not.toBeNull()
}
// 12th tick triggers stuck mitigation
scene.controller.tick()
expect(scene.controller.navTarget).toBeNull()
})
})