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