/** * 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 { 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 type { SceneEntrance, SceneWarp } from '../src/game/level-links.ts' import { subTileToScene } from '../src/scene/transition.ts' import { getLevelNames } from '../src/game/level-names-zh.ts' import { SceneMouseController, screenToWorld, facingOf, PASSIVE_SKILL_IDS, isPassiveSkill, getSkillRange, getSkillMana, TELEKINESIS_RANGE_PX, getWarpLabelWorldPos, getEntranceLabelWorldPos, hitTestWarp, hitTestEntrance, isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile, 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', 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('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 }> warps?: SceneWarp[] entrances?: SceneEntrance[] portalSlot?: { mouthInLevel: (levelId: number) => { x: number; y: number } | null } onEnterPortal?: () => void onEnterWarp?: (warp: SceneWarp) => void onEnterEntrance?: (entrance: SceneEntrance) => void isTravelling?: () => boolean labelsContainer?: HTMLElement | null 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: options?.entrances ?? [], warps: options?.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, portalSlot: options?.portalSlot as any, onEnterPortal: options?.onEnterPortal, onEnterWarp: options?.onEnterWarp, onEnterEntrance: options?.onEnterEntrance, isTravelling: options?.isTravelling, labelsContainer: options?.labelsContainer, }) 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() }) }) describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Labels', () => { it('disables KeyG so pressing KeyG no longer triggers takeUse()', () => { const win = new MockWindow() const input = new KeyboardInput(win as unknown as Window) input.attach() expect(input.takeUse()).toBe(false) win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'KeyG', key: 'g' })) expect(input.takeUse()).toBe(false) for (let i = 0; i < 25; i++) { expect(input.takeUse()).toBe(false) } win.dispatchEvent(new SyntheticKeyboardEvent('keyup', { code: 'KeyG', key: 'g' })) expect(input.takeUse()).toBe(false) }) it('immediately triggers onEnterWarp without setting navTarget when left-clicking an in-range SceneWarp', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, // Den of Evil warpId: 4, x: 30, y: 30, arriveX: 32, arriveY: 30, exitWalkX: 32, exitWalkY: 30, selectX: 0, selectY: 0, selectDX: 0, selectDY: 0, } const anchorPos = subTileToScene(warp.x, warp.y, grid) const playerPos = subTileToScene(31, 30, grid) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos, warps: [warp], onEnterWarp: (w) => enteredWarps.push(w), }) // Click directly on the warp anchor (relative to camera centered at playerPos.x, playerPos.y - 16) const clientX = 960 + (anchorPos.x - playerPos.x) const clientY = 540 + (anchorPos.y - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) expect(enteredWarps).toEqual([warp]) expect(scene.controller.navTarget).toBeNull() expect(scene.controller.pendingInteraction).toBeNull() expect(scene.status.textContent).toContain('邪恶洞窟') }) it('sets navTarget to walkable arrivePos when left-clicking a distant SceneWarp and triggers onEnterWarp on tick() upon arrival', () => { const grid = { originX: 0, originY: 0 } // Warp where arriveX/arriveY is 7 sub-tiles from anchor (like Harem / Catacombs stairs) const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Catacombs Level 2', toLevelId: 35, // Catacombs Level 2 warpId: 18, x: 40, y: 20, arriveX: 47, arriveY: 20, exitWalkX: 47, exitWalkY: 20, selectX: -54, selectY: -39, selectDX: 121, selectDY: 102, } const anchorPos = subTileToScene(warp.x, warp.y, grid) const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) const playerPos = subTileToScene(70, 50, grid) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...playerPos }, warps: [warp], onEnterWarp: (w) => enteredWarps.push(w), }) const clientX = 960 + (anchorPos.x - playerPos.x) const clientY = 540 + (anchorPos.y - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX, clientY, button: 0, })) expect(enteredWarps).toHaveLength(0) expect(scene.controller.navTarget).toEqual(arrivePos) expect(scene.controller.pendingInteraction?.kind).toBe('warp') // Move partway toward arrivePos: still out of range scene.engine.world.player.x = (playerPos.x + arrivePos.x) * 0.5 scene.engine.world.player.y = (playerPos.y + arrivePos.y) * 0.5 const step = scene.controller.tick() expect(enteredWarps).toHaveLength(0) expect(step.movement.x !== 0 || step.movement.y !== 0).toBe(true) // Arrive within trigger range of arrivePos scene.engine.world.player.x = arrivePos.x scene.engine.world.player.y = arrivePos.y const arriveTick = scene.controller.tick() expect(enteredWarps).toEqual([warp]) expect(scene.controller.navTarget).toBeNull() expect(scene.controller.pendingInteraction).toBeNull() expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) }) it('resolves and activates a SceneWarp when left-clicking inside its LvlWarp.txt (selectX, selectY, selectDX, selectDY) bounding box', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Harem Level 1', toLevelId: 50, // Harem Level 1 warpId: 23, x: 35, y: 35, arriveX: 37, arriveY: 35, exitWalkX: 37, exitWalkY: 35, selectX: -120, selectY: -100, selectDX: 180, selectDY: 140, } const anchorPos = subTileToScene(warp.x, warp.y, grid) const playerPos = subTileToScene(36, 35, grid) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...playerPos }, warps: [warp], onEnterWarp: (w) => enteredWarps.push(w), }) // Click near the top-left corner of the LvlWarp.txt selection box: // ~140px from anchorPos (well outside radial proximity 56px, but inside [-120..60, -100..40]) const clickWorldX = anchorPos.x - 110 const clickWorldY = anchorPos.y - 90 const clientX = 960 + (clickWorldX - playerPos.x) const clientY = 540 + (clickWorldY - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) expect(enteredWarps).toEqual([warp]) expect(scene.controller.navTarget).toBeNull() }) it('paths to arrivePos and triggers onEnterEntrance when left-clicking a SceneEntrance', () => { const grid = { originX: 0, originY: 0 } const entrance: SceneEntrance = { toLevelId: 2, // Blood Moor side: 'east', label: 'Blood Moor Exit', x: 25, y: 25, arriveX: 31, arriveY: 25, } const anchorPos = subTileToScene(entrance.x, entrance.y, grid) const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid) const playerPos = subTileToScene(60, 45, grid) const enteredEntrances: SceneEntrance[] = [] const scene = createTestScene({ playerPos: { ...playerPos }, entrances: [entrance], onEnterEntrance: (e) => enteredEntrances.push(e), }) const clientX = 960 + (anchorPos.x - playerPos.x) const clientY = 540 + (anchorPos.y - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX, clientY, button: 0, })) expect(enteredEntrances).toHaveLength(0) expect(scene.controller.navTarget).toEqual(arrivePos) expect(scene.controller.pendingInteraction?.kind).toBe('entrance') // Move player to arrivePos and tick scene.engine.world.player.x = arrivePos.x scene.engine.world.player.y = arrivePos.y scene.controller.tick() expect(enteredEntrances).toEqual([entrance]) expect(scene.controller.navTarget).toBeNull() expect(scene.controller.pendingInteraction).toBeNull() expect(scene.status.textContent).toContain('鲜血荒地') }) it('allows Telekinesis (Skill 43) right-click casts to activate a clicked SceneWarp within TELEKINESIS_RANGE_PX (320px)', () => { const grid = { originX: 0, originY: 0 } const nearWarp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, warpId: 4, x: 30, y: 30, arriveX: 32, arriveY: 30, exitWalkX: 32, exitWalkY: 30, selectX: -48, selectY: -48, selectDX: 96, selectDY: 96, } const farWarp: SceneWarp = { source: 'tile', direction: 'down', label: 'Cold Plains Cave', toLevelId: 9, warpId: 5, x: 60, y: 30, arriveX: 62, arriveY: 30, exitWalkX: 62, exitWalkY: 30, selectX: -48, selectY: -48, selectDX: 96, selectDY: 96, } const nearPos = subTileToScene(nearWarp.x, nearWarp.y, grid) const farPos = subTileToScene(farWarp.x, farWarp.y, grid) // Place player 220px away from nearWarp (< 320px) and > 320px from farWarp const playerPos = { x: nearPos.x - 220, y: nearPos.y } expect(Math.hypot(nearPos.x - playerPos.x, nearPos.y - playerPos.y)).toBeLessThanOrEqual(TELEKINESIS_RANGE_PX) expect(Math.hypot(farPos.x - playerPos.x, farPos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos, rightSkillId: 43, // Telekinesis warps: [nearWarp, farWarp], onEnterWarp: (w) => enteredWarps.push(w), }) // Right-click farWarp (> 320px) -> rejected without deducting mana const startMana = scene.hudManager.mana scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: 960 + (farPos.x - playerPos.x), clientY: 540 + (farPos.y - (playerPos.y - 16)), button: 2, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 960 + (farPos.x - playerPos.x), clientY: 540 + (farPos.y - (playerPos.y - 16)), button: 2, })) expect(enteredWarps).toHaveLength(0) expect(scene.hudManager.mana).toBe(startMana) // Right-click nearWarp (<= 320px) -> deducts mana and activates warp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: 960 + (nearPos.x - playerPos.x), clientY: 540 + (nearPos.y - (playerPos.y - 16)), button: 2, })) expect(enteredWarps).toEqual([nearWarp]) expect(scene.hudManager.mana).toBeLessThan(startMana) }) it('renders overhead destination labels in labelsContainer and updates pointer cursor + badge highlight on hover', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, // 邪恶洞窟 warpId: 4, x: 30, y: 30, arriveX: 32, arriveY: 30, exitWalkX: 32, exitWalkY: 30, selectX: -60, selectY: -60, selectDX: 120, selectDY: 90, } const entrance: SceneEntrance = { toLevelId: 2, // 鲜血荒地 side: 'east', label: 'Blood Moor Exit', x: 38, y: 30, arriveX: 40, arriveY: 30, } const playerPos = subTileToScene(34, 30, grid) const warpAnchor = subTileToScene(warp.x, warp.y, grid) const entranceAnchor = subTileToScene(entrance.x, entrance.y, grid) const children: HTMLDivElement[] = [] const mockLabelsContainer = { children, appendChild(el: HTMLDivElement) { children.push(el) return el }, } as unknown as HTMLElement const scene = createTestScene({ playerPos, warps: [warp], entrances: [entrance], labelsContainer: mockLabelsContainer, }) const badges = children expect(badges.length).toBe(2) expect(badges[0]!.textContent).toBe('通往 邪恶洞窟') expect(badges[1]!.textContent).toBe('通往 鲜血荒地') expect(badges[0]!.dataset.hovered).toBe('false') expect(badges[1]!.dataset.hovered).toBe('false') expect(scene.canvas.style.cursor).not.toBe('pointer') // Hover over the SceneWarp bounding box scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: 960 + (warpAnchor.x - playerPos.x), clientY: 540 + (warpAnchor.y - (playerPos.y - 16)), })) expect(scene.controller.hoveredWarp).toBe(warp) expect(scene.controller.hoveredEntrance).toBeNull() expect(scene.canvas.style.cursor).toBe('pointer') expect(badges[0]!.dataset.hovered).toBe('true') expect(badges[0]!.className).toContain('is-hovered') expect(badges[1]!.dataset.hovered).toBe('false') // Hover over the SceneEntrance scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: 960 + (entranceAnchor.x - playerPos.x), clientY: 540 + (entranceAnchor.y - (playerPos.y - 16)), })) expect(scene.controller.hoveredWarp).toBeNull() expect(scene.controller.hoveredEntrance).toBe(entrance) expect(scene.canvas.style.cursor).toBe('pointer') expect(badges[0]!.dataset.hovered).toBe('false') expect(badges[1]!.dataset.hovered).toBe('true') expect(badges[1]!.className).toContain('is-hovered') // Move cursor to empty ground scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: 100, clientY: 100, })) expect(scene.controller.hoveredWarp).toBeNull() expect(scene.controller.hoveredEntrance).toBeNull() expect(scene.canvas.style.cursor).toBe('') expect(badges[0]!.dataset.hovered).toBe('false') expect(badges[1]!.dataset.hovered).toBe('false') }) it('paths around a concave dungeon wall obstacle when clicking a distant SceneWarp without false stuck cancellation', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Catacombs Level 3', toLevelId: 36, warpId: 19, x: 20, y: 33, arriveX: 20, arriveY: 32, exitWalkX: 20, exitWalkY: 32, selectX: -48, selectY: -48, selectDX: 96, selectDY: 96, } const playerStart = subTileToScene(20, 20, grid) const warpAnchor = subTileToScene(warp.x, warp.y, grid) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...playerStart }, warps: [warp], onEnterWarp: (w) => enteredWarps.push(w), }) // Build a concave U-shaped dungeon wall between (20, 20) and (20, 32): // Horizontal bar at y = 26, x = 16..24, plus side walls extending up toward player at x = 16 and x = 24, y = 22..26 const gridW = scene.runtime.grid.gridWidth const blocked = scene.runtime.grid.blocked as Uint8Array for (let x = 16; x <= 24; x++) { blocked[26 * gridW + x] = 1 } for (let y = 22; y <= 26; y++) { blocked[y * gridW + 16] = 1 blocked[y * gridW + 24] = 1 } // Left-click the distant warp behind the concave wall const clientX = 960 + (warpAnchor.x - playerStart.x) const clientY = 540 + (warpAnchor.y - (playerStart.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX, clientY, button: 0, })) expect(scene.controller.pendingInteraction?.kind).toBe('warp') expect(enteredWarps).toHaveLength(0) // Step simulation ticks: player must route out of the concave pocket, around x < 16 or x > 24, and arrive at (20, 32) let detouredOutsideWall = false for (let stepIdx = 0; stepIdx < 160 && enteredWarps.length === 0; stepIdx++) { const { movement } = scene.controller.tick() if (enteredWarps.length > 0) break expect(movement.x !== 0 || movement.y !== 0).toBe(true) scene.engine.world.player.x += movement.x * 5 scene.engine.world.player.y += movement.y * 5 const sub = sceneToSubTile(scene.engine.world.player.x, scene.engine.world.player.y, grid) expect(blocked[sub.y * gridW + sub.x]).toBe(0) if (sub.x < 16 || sub.x > 24) { detouredOutsideWall = true } } expect(detouredOutsideWall).toBe(true) expect(enteredWarps).toEqual([warp]) expect(scene.controller.navTarget).toBeNull() expect(scene.controller.pendingInteraction).toBeNull() }) it('hit-tests and activates overhead destination badges across their full rectangular footprint', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, warpId: 4, x: 30, y: 30, arriveX: 31, arriveY: 30, exitWalkX: 31, exitWalkY: 30, selectX: -50, selectY: -60, selectDX: 100, selectDY: 80, } const playerPos = subTileToScene(31, 30, grid) const labelPos = getWarpLabelWorldPos(warp, grid) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...playerPos }, warps: [warp], onEnterWarp: (w) => enteredWarps.push(w), }) // Point near the top-right of the rendered overhead badge: // +58px right and -18px above labelPos (Euclidean distance = 60.73px > 56px radial threshold, and above selectY box) const badgeClickX = labelPos.x + 58 const badgeClickY = labelPos.y - 18 const clientX = 960 + (badgeClickX - playerPos.x) const clientY = 540 + (badgeClickY - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX, clientY, })) expect(scene.controller.hoveredWarp).toBe(warp) expect(scene.canvas.style.cursor).toBe('pointer') scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) expect(enteredWarps).toEqual([warp]) }) it('prioritizes monsters and ground items over SceneWarp when overlapping inside a LvlWarp.txt box for both hover and click', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Catacombs Level 1', toLevelId: 34, warpId: 17, x: 35, y: 35, arriveX: 36, arriveY: 35, exitWalkX: 36, exitWalkY: 35, selectX: -120, selectY: -100, selectDX: 180, selectDY: 140, } const anchorPos = subTileToScene(warp.x, warp.y, grid) const playerPos = subTileToScene(36, 35, grid) const monsterPos = { x: anchorPos.x - 20, y: anchorPos.y - 10 } const enteredWarps: SceneWarp[] = [] const children: HTMLDivElement[] = [] const mockLabelsContainer = { children, appendChild(el: HTMLDivElement) { children.push(el) return el }, } as unknown as HTMLElement const scene = createTestScene({ playerPos: { ...playerPos }, leftSkillId: 0, monsters: [ { id: 'mon-overlap', name: 'Fallen', x: monsterPos.x, y: monsterPos.y, hp: 40 }, ], warps: [warp], onEnterWarp: (w) => enteredWarps.push(w), labelsContainer: mockLabelsContainer, }) scene.engine.world.monsters.length = 1 // 1. Hovering over the living monster inside the warp's LvlWarp.txt box must NOT highlight the warp badge const monClientX = 960 + (monsterPos.x - playerPos.x) const monClientY = 540 + (monsterPos.y - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: monClientX, clientY: monClientY, })) expect(scene.controller.hoveredWarp).toBeNull() expect(children[0]!.dataset.hovered).toBe('false') // 2. Left-clicking the living monster attacks the monster instead of triggering the warp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: monClientX, clientY: monClientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: monClientX, clientY: monClientY, button: 0, })) expect(enteredWarps).toHaveLength(0) expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(40) // 3. Place a ground item at that spot and verify GroundItem > SceneWarp scene.engine.world.monsters[0]!.hp = 0 scene.engine.world.monsters[0]!.state = 'dead' scene.engine.groundItems.clear() const groundItem = scene.engine.groundItems.add( { isGold: true, code: 'gld', name: 'Gold' }, 0, 0, monsterPos.x, monsterPos.y, { amount: 25 }, ) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: monClientX, clientY: monClientY, })) expect(scene.controller.hoveredGroundItem?.id).toBe(groundItem.id) expect(scene.controller.hoveredWarp).toBeNull() expect(children[0]!.dataset.hovered).toBe('false') scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: monClientX, clientY: monClientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: monClientX, clientY: monClientY, button: 0, })) expect(enteredWarps).toHaveLength(0) expect(scene.engine.groundItems.count).toBe(0) // 4. Once both the monster is dead and the ground item is picked up, hovering and clicking that exact spot inside the LvlWarp.txt box highlights and activates the warp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: monClientX, clientY: monClientY, })) expect(scene.controller.hoveredWarp).toBe(warp) expect(children[0]!.dataset.hovered).toBe('true') scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: monClientX, clientY: monClientY, button: 0, })) expect(enteredWarps).toEqual([warp]) }) it('maintains exact overhead badge screen alignment across non-unit camera zooms, camera scrolling, and clearTarget()', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, warpId: 4, x: 30, y: 30, arriveX: 31, arriveY: 30, exitWalkX: 31, exitWalkY: 30, selectX: -60, selectY: -60, selectDX: 120, selectDY: 90, } const entrance: SceneEntrance = { toLevelId: 2, side: 'east', label: 'Blood Moor', x: 36, y: 28, arriveX: 38, arriveY: 28, } const playerPos = subTileToScene(32, 30, grid) const warpLabelWorld = getWarpLabelWorldPos(warp, grid) const entranceLabelWorld = getEntranceLabelWorldPos(entrance, grid) const children: HTMLDivElement[] = [] const mockLabelsContainer = { children, appendChild(el: HTMLDivElement) { children.push(el) return el }, } as unknown as HTMLElement const scene = createTestScene({ playerPos: { ...playerPos }, warps: [warp], entrances: [entrance], labelsContainer: mockLabelsContainer, }) for (const zoom of [1.5, 0.75]) { scene.camera.zoom = zoom scene.controller.updateOverheadLabels(playerPos.x, playerPos.y - 16) const warpBadge = children[0]! const entranceBadge = children[1]! const warpDomX = parseFloat(warpBadge.style.left) const warpDomY = parseFloat(warpBadge.style.top) + 2 const entranceDomX = parseFloat(entranceBadge.style.left) const entranceDomY = parseFloat(entranceBadge.style.top) + 2 const invWarp = screenToWorld({ clientX: warpDomX, clientY: warpDomY, rect: scene.canvas.getBoundingClientRect(), canvas: scene.canvas, camX: playerPos.x, camY: playerPos.y - 16, cameraZoom: zoom, }) const invEntrance = screenToWorld({ clientX: entranceDomX, clientY: entranceDomY, rect: scene.canvas.getBoundingClientRect(), canvas: scene.canvas, camX: playerPos.x, camY: playerPos.y - 16, cameraZoom: zoom, }) expect(Math.hypot(invWarp.x - warpLabelWorld.x, invWarp.y - warpLabelWorld.y)).toBeLessThan(1e-6) expect(Math.hypot(invEntrance.x - entranceLabelWorld.x, invEntrance.y - entranceLabelWorld.y)).toBeLessThan(1e-6) // Hover the projected badge position at this zoom scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: warpDomX, clientY: warpDomY, })) expect(scene.controller.hoveredWarp).toBe(warp) expect(warpBadge.dataset.hovered).toBe('true') // If the camera scrolls away while the pointer stays stationary, updateOverheadLabels clears hover scene.controller.updateOverheadLabels(playerPos.x + 600, playerPos.y + 600) expect(scene.controller.hoveredWarp).toBeNull() expect(warpBadge.dataset.hovered).toBe('false') // Re-hover at original camera position, then verify clearTarget() immediately clears badge hover scene.controller.updateOverheadLabels(playerPos.x, playerPos.y - 16) expect(scene.controller.hoveredWarp).toBe(warp) expect(warpBadge.dataset.hovered).toBe('true') scene.controller.clearTarget() expect(scene.controller.hoveredWarp).toBeNull() expect(warpBadge.dataset.hovered).toBe('false') expect(warpBadge.className).not.toContain('is-hovered') } }) it('verifies all 365 baked map packs (495 SceneWarp and 166 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(365) 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) } } expect(warpCount).toBe(495) expect(nonZeroBoxWarps).toBe(464) expect(zeroBoxWarps).toBe(31) // 166 since the Act I outdoor levels come from the DRLG port: D2 has no // Stony Field <-> Dark Wood seam (DrlgOutPlace.cpp gAct1WildernessDrlgLink / // gAct1MonasteryDrlgLink put them in separate groups, joined only through // the Underground Passage), so the 2 seam entrances the old generator // invented in each of the 3 act copies are gone. expect(entranceCount).toBe(166) }) it('ignores unresolved warps and entrances (toLevelId <= 0) for overhead badges, hover, left-click, and Telekinesis', () => { const grid = { originX: 0, originY: 0 } const unresolvedWarp: SceneWarp = { source: 'tile', direction: 'down', label: 'Unresolved Warp', toLevelId: -1, warpId: 4, x: 30, y: 30, arriveX: 31, arriveY: 30, exitWalkX: 31, exitWalkY: 30, selectX: -60, selectY: -60, selectDX: 120, selectDY: 90, } const unresolvedEntrance: SceneEntrance = { toLevelId: -1, side: 'east', label: 'Unresolved Seam', x: 34, y: 30, arriveX: 33, arriveY: 30, } const playerPos = subTileToScene(31, 30, grid) const warpAnchor = subTileToScene(unresolvedWarp.x, unresolvedWarp.y, grid) const enteredWarps: SceneWarp[] = [] const enteredEntrances: SceneEntrance[] = [] expect(hitTestWarp(unresolvedWarp, warpAnchor.x, warpAnchor.y, grid).hit).toBe(false) const entranceAnchor = subTileToScene(unresolvedEntrance.x, unresolvedEntrance.y, grid) expect(hitTestEntrance(unresolvedEntrance, entranceAnchor.x, entranceAnchor.y, grid).hit).toBe(false) const children: HTMLDivElement[] = [] const mockLabelsContainer = { children, appendChild(el: HTMLDivElement) { children.push(el) return el }, } as unknown as HTMLElement const scene = createTestScene({ playerPos: { ...playerPos }, rightSkillId: 43, warps: [unresolvedWarp], entrances: [unresolvedEntrance], onEnterWarp: (w) => enteredWarps.push(w), onEnterEntrance: (e) => enteredEntrances.push(e), labelsContainer: mockLabelsContainer, }) // No overhead badges should be rendered for unresolved links expect(children.filter(el => !el.hidden)).toHaveLength(0) const clientX = 960 + (warpAnchor.x - playerPos.x) const clientY = 540 + (warpAnchor.y - (playerPos.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX, clientY, })) expect(scene.controller.hoveredWarp).toBeNull() expect(scene.controller.hoveredEntrance).toBeNull() expect(scene.canvas.style.cursor).toBe('') // Left-click should walk on ground rather than activating unresolved warp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX, clientY, button: 0, })) expect(enteredWarps).toHaveLength(0) expect(enteredEntrances).toHaveLength(0) // Right-click Telekinesis should not trigger unresolved warp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 2, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX, clientY, button: 2, })) expect(enteredWarps).toHaveLength(0) }) it('strictly enforces TELEKINESIS_RANGE_PX against warp anchorPos/arrivePos and does not spam Telekinesis while right mouse is held', () => { const grid = { originX: 0, originY: 0 } // Warp with a wide selection box: anchor and arrive are > 320px away from player, // but the left edge of the selection box is < 320px from player. const wideBoxWarp: SceneWarp = { source: 'tile', direction: 'down', label: 'Harem Level 1', toLevelId: 50, warpId: 23, x: 50, y: 30, arriveX: 52, arriveY: 30, exitWalkX: 52, exitWalkY: 30, selectX: -140, selectY: -80, selectDX: 200, selectDY: 140, } const anchorPos = subTileToScene(wideBoxWarp.x, wideBoxWarp.y, grid) const arrivePos = subTileToScene(wideBoxWarp.arriveX, wideBoxWarp.arriveY, grid) // Place player 380px left of anchorPos (> 320px), so anchorPos-120px is 260px from player (<= 320px) const playerPos = { x: anchorPos.x - 380, y: anchorPos.y } expect(Math.hypot(anchorPos.x - playerPos.x, anchorPos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX) expect(Math.hypot(arrivePos.x - playerPos.x, arrivePos.y - playerPos.y)).toBeGreaterThan(TELEKINESIS_RANGE_PX) const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...playerPos }, rightSkillId: 43, warps: [wideBoxWarp], onEnterWarp: (w) => enteredWarps.push(w), }) const nearCornerWorldX = anchorPos.x - 120 const nearCornerWorldY = anchorPos.y expect(Math.hypot(nearCornerWorldX - playerPos.x, nearCornerWorldY - playerPos.y)).toBeLessThanOrEqual(TELEKINESIS_RANGE_PX) const startMana = scene.hudManager.mana scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: 960 + (nearCornerWorldX - playerPos.x), clientY: 540 + (nearCornerWorldY - (playerPos.y - 16)), button: 2, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 960 + (nearCornerWorldX - playerPos.x), clientY: 540 + (nearCornerWorldY - (playerPos.y - 16)), button: 2, })) // Must reject activation and refund mana because warp anchor & arrive are > 320px away expect(enteredWarps).toHaveLength(0) expect(scene.hudManager.mana).toBe(startMana) expect(scene.status.textContent).toBe('目标超出心灵传动范围!') // Move player within range (200px left of anchorPos) and hold right mouse down across 5 ticks scene.engine.world.player.x = anchorPos.x - 200 scene.engine.world.player.y = anchorPos.y const inRangeClientX = 960 + 200 const inRangeClientY = 540 - 16 scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: inRangeClientX, clientY: inRangeClientY, button: 2, buttons: 2, })) expect(enteredWarps).toHaveLength(1) const manaAfterSingleCast = scene.hudManager.mana expect(manaAfterSingleCast).toBeLessThan(startMana) // Holding right button across subsequent ticks must NOT re-cast Telekinesis 25 times/sec for (let i = 0; i < 5; i++) { scene.controller.tick() } expect(enteredWarps).toHaveLength(1) expect(scene.hudManager.mana).toBe(manaAfterSingleCast) }) 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('expands badge hit-testing width for long Chinese destination names and cleans up state on detach()', () => { const grid = { originX: 0, originY: 0 } // Level 66: 塔·拉夏古墓 (1) -> badgeText = '通往 塔·拉夏古墓 (1)' (13 chars, half-width = 99px > 64px) const tombWarp: SceneWarp = { source: 'tile', direction: 'down', label: 'Tal Rasha Tomb', toLevelId: 66, warpId: 49, x: 30, y: 30, arriveX: 31, arriveY: 30, exitWalkX: 31, exitWalkY: 30, selectX: -40, selectY: -40, selectDX: 80, selectDY: 80, } const labelPos = getWarpLabelWorldPos(tombWarp, grid) // Click 85px to the right of badge center (> 64px default half-width, within 13-char badge half-width 99px) const wideClick = hitTestWarp(tombWarp, labelPos.x + 85, labelPos.y - 12, grid) expect(wideClick.hit).toBe(true) const playerPos = subTileToScene(31, 30, grid) const children: HTMLDivElement[] = [] const mockLabelsContainer = { children, appendChild(el: HTMLDivElement) { children.push(el) return el }, } as unknown as HTMLElement const scene = createTestScene({ playerPos: { ...playerPos }, warps: [tombWarp], labelsContainer: mockLabelsContainer, }) // Hover over the wide badge edge scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX: 960 + ((labelPos.x + 85) - playerPos.x), clientY: 540 + ((labelPos.y - 12) - (playerPos.y - 16)), })) expect(scene.controller.hoveredWarp).toBe(tombWarp) expect(scene.canvas.style.cursor).toBe('pointer') expect(children[0]!.hidden).toBe(false) // Detach must clear hover, reset cursor, and hide pooled labels scene.controller.detach() expect(scene.controller.hoveredWarp).toBeNull() expect(scene.canvas.style.cursor).toBe('') expect(children[0]!.hidden).toBe(true) }) 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) }) it('prioritizes an open Town Portal over an overlapping SceneWarp for hover, left-click, and Telekinesis across the full [portalPos.y - 80, portalPos.y] vertical oval', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, warpId: 4, x: 30, y: 30, arriveX: 31, arriveY: 30, exitWalkX: 31, exitWalkY: 30, selectX: -100, selectY: -120, selectDX: 200, selectDY: 160, } const portalPos = subTileToScene(30, 30, grid) const playerPos = subTileToScene(31, 30, grid) let enteredPortalCount = 0 const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...playerPos }, rightSkillId: 43, // Telekinesis warps: [warp], portalSlot: { mouthInLevel: () => ({ x: 30, y: 30 }), }, onEnterPortal: () => { enteredPortalCount += 1 }, onEnterWarp: (w) => enteredWarps.push(w), }) // Upper part of the Town Portal oval: (portalPos.x + 24, portalPos.y - 75) // Within 48px of [portalPos.y - 80, portalPos.y], and also inside warp's LvlWarp.txt box. const targetWorldX = portalPos.x + 24 const targetWorldY = portalPos.y - 75 const clientX = 960 + (targetWorldX - playerPos.x) const clientY = 540 + (targetWorldY - (playerPos.y - 16)) // 1. Hovering the portal oval suppresses hoveredWarp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', { clientX, clientY, })) expect(scene.controller.hoveredWarp).toBeNull() // 2. Left-clicking the portal oval enters the Town Portal instead of the SceneWarp scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, })) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX, clientY, button: 0, })) expect(enteredPortalCount).toBe(1) expect(enteredWarps).toHaveLength(0) // 3. Right-clicking with Telekinesis (Skill 43) near the top of the portal oval (targetY = portalPos.y - 95, // distance to clampedPortalY (portalPos.y - 80) ishypot(30, -15) = 33.5px <= 64px, whereas distance to // portalPos.y - 40 is hypot(30, -55) = 62.6px and at (40, -95) is hypot(40, -15) = 42.7px <= 64px vs hypot(40, -55) = 68.0px > 64px) // enters the Town Portal rather than the underlying SceneWarp. const tkWorldX = portalPos.x + 40 const tkWorldY = portalPos.y - 95 scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX: 960 + (tkWorldX - playerPos.x), clientY: 540 + (tkWorldY - (playerPos.y - 16)), button: 2, })) expect(enteredPortalCount).toBe(2) expect(enteredWarps).toHaveLength(0) }) it('clears isLeftMouseDown and activeClickType on warp/entrance/portal trigger and suppresses pointer/tick actions while isTravelling() is true', () => { const grid = { originX: 0, originY: 0 } const warp: SceneWarp = { source: 'tile', direction: 'down', label: 'Den of Evil', toLevelId: 8, warpId: 4, x: 30, y: 30, arriveX: 31, arriveY: 30, exitWalkX: 31, exitWalkY: 30, selectX: -48, selectY: -48, selectDX: 96, selectDY: 96, } const anchorPos = subTileToScene(warp.x, warp.y, grid) const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid) const distantPlayer = subTileToScene(45, 30, grid) let travelling = false const enteredWarps: SceneWarp[] = [] const scene = createTestScene({ playerPos: { ...distantPlayer }, warps: [warp], isTravelling: () => travelling, onEnterWarp: (w) => { enteredWarps.push(w) travelling = true }, }) // Hold left mouse button down on distant warp (no pointerup) const clientX = 960 + (anchorPos.x - distantPlayer.x) const clientY = 540 + (anchorPos.y - (distantPlayer.y - 16)) scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, buttons: 1, })) expect(scene.controller.isLeftMouseDown).toBe(true) expect(scene.controller.activeClickType).toBe('warp') expect(scene.controller.pendingInteraction?.kind).toBe('warp') // Arrive at warp while still holding left mouse button down scene.engine.world.player.x = arrivePos.x scene.engine.world.player.y = arrivePos.y const arriveTick = scene.controller.tick() expect(enteredWarps).toEqual([warp]) expect(arriveTick.movement).toEqual({ x: 0, y: 0 }) // Left mouse hold and activeClickType must be cleared immediately upon transition trigger expect(scene.controller.isLeftMouseDown).toBe(false) expect(scene.controller.activeClickType).toBe('none') // While travelling is true, new pointerdown events and tick() calls must be suppressed scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', { clientX, clientY, button: 0, buttons: 1, })) expect(scene.controller.isLeftMouseDown).toBe(false) expect(enteredWarps).toHaveLength(1) expect(scene.controller.tick()).toEqual({ movement: { x: 0, y: 0 }, attacking: false }) }) })