913 lines
30 KiB
TypeScript
913 lines
30 KiB
TypeScript
/**
|
|
* Adversarial Challenger Stress Testing Suite for Milestone 2 Left-Click & Shift Controls
|
|
* (Solution Stress Testing Playbook: Differential Fuzzing, Boundary Values, Invariant Checks)
|
|
*/
|
|
|
|
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,
|
|
projectNpcLabel,
|
|
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' }
|
|
rect = { left: 0, top: 0, width: 1920, height: 1080 }
|
|
getBoundingClientRect() {
|
|
return this.rect
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
const mockWin = new MockWindow()
|
|
// @ts-ignore
|
|
globalThis.window = mockWin
|
|
// @ts-ignore
|
|
globalThis.HTMLInputElement = class HTMLInputElement {}
|
|
// @ts-ignore
|
|
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
|
|
|
function createChallengerScene(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
|
|
canvasRect?: { left: number; top: number; width: number; height: number }
|
|
canvasSize?: { width: number; height: number }
|
|
cameraZoom?: number
|
|
}) {
|
|
const canvas = new MockCanvas() as unknown as HTMLCanvasElement & { rect: { left: number; top: number; width: number; height: number } }
|
|
if (options?.canvasRect) {
|
|
canvas.rect = options.canvasRect
|
|
canvas.clientWidth = options.canvasRect.width
|
|
canvas.clientHeight = options.canvasRect.height
|
|
}
|
|
if (options?.canvasSize) {
|
|
canvas.width = options.canvasSize.width
|
|
canvas.height = options.canvasSize.height
|
|
}
|
|
|
|
const win = new MockWindow()
|
|
const input = new KeyboardInput(win as unknown as Window)
|
|
input.attach()
|
|
|
|
const dummyTerrain: WorldMapProvider = {
|
|
widthPx: 4000,
|
|
heightPx: 4000,
|
|
overlap: options?.blockedOverlap ?? (() => 0),
|
|
}
|
|
|
|
const dummyOpts: GameEngineOptions = {
|
|
spawn: options?.playerPos ?? { x: 1000, y: 1000 },
|
|
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, 4000, 4000)
|
|
camera.zoom = options?.cameraZoom ?? 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: 4000,
|
|
heightPx: 4000,
|
|
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('Challenger Stress Tests: Milestone 2 Left-Click & Shift Controls', () => {
|
|
describe('1. screenToWorld projection with varying camera zoom levels and DPRs', () => {
|
|
it('exact round-trip invertibility across zoom levels [0.4x..8.0x] and DPRs [1.0..4.0]', () => {
|
|
const zooms = [0.4, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0]
|
|
const dprs = [1.0, 1.25, 1.333, 1.5, 2.0, 2.5, 3.0, 4.0]
|
|
const viewports = [
|
|
{ width: 800, height: 600, left: 0, top: 0 },
|
|
{ width: 1280, height: 720, left: 10, top: 20 },
|
|
{ width: 1920, height: 1080, left: 100, top: 50 },
|
|
{ width: 2560, height: 1440, left: 0, top: 0 },
|
|
{ width: 3440, height: 1440, left: 50, top: 0 }, // ultrawide
|
|
]
|
|
|
|
let testIterations = 0
|
|
for (const zoom of zooms) {
|
|
for (const dpr of dprs) {
|
|
for (const vp of viewports) {
|
|
const rect = { left: vp.left, top: vp.top, width: vp.width, height: vp.height }
|
|
const canvas = {
|
|
width: Math.round(vp.width * dpr),
|
|
height: Math.round(vp.height * dpr),
|
|
}
|
|
const camX = 1500
|
|
const camY = 1200
|
|
|
|
// Test 20 random points per configuration
|
|
for (let i = 0; i < 20; i++) {
|
|
testIterations++
|
|
const originalWorldX = 500 + (i * 97) % 2000
|
|
const originalWorldY = 400 + (i * 113) % 2000
|
|
|
|
// Forward projection to screen client coordinates:
|
|
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
|
|
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
|
|
const clientX = rect.left + rect.width * 0.5 + (originalWorldX - camX) * zoom * scaleX
|
|
const clientY = rect.top + rect.height * 0.5 + (originalWorldY - camY) * zoom * scaleY
|
|
|
|
// Invert back via screenToWorld:
|
|
const unprojected = screenToWorld({
|
|
clientX,
|
|
clientY,
|
|
rect,
|
|
canvas,
|
|
camX,
|
|
camY,
|
|
cameraZoom: zoom,
|
|
})
|
|
|
|
expect(Math.abs(unprojected.x - originalWorldX)).toBeLessThan(1e-4)
|
|
expect(Math.abs(unprojected.y - originalWorldY)).toBeLessThan(1e-4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(testIterations).toBe(zooms.length * dprs.length * viewports.length * 20)
|
|
})
|
|
|
|
it('center of viewport strictly projects to camera focal position regardless of zoom or DPR', () => {
|
|
const dprs = [1.0, 1.5, 2.0, 3.0]
|
|
const zooms = [0.4, 1.0, 2.5, 8.0]
|
|
|
|
for (const dpr of dprs) {
|
|
for (const zoom of zooms) {
|
|
const rect = { left: 80, top: 40, width: 1600, height: 900 }
|
|
const canvas = { width: Math.round(1600 * dpr), height: Math.round(900 * dpr) }
|
|
const camX = 777.5
|
|
const camY = 888.25
|
|
|
|
const centerClientX = rect.left + rect.width * 0.5 // 80 + 800 = 880
|
|
const centerClientY = rect.top + rect.height * 0.5 // 40 + 450 = 490
|
|
|
|
const world = screenToWorld({
|
|
clientX: centerClientX,
|
|
clientY: centerClientY,
|
|
rect,
|
|
canvas,
|
|
camX,
|
|
camY,
|
|
cameraZoom: zoom,
|
|
})
|
|
|
|
expect(world.x).toBeCloseTo(camX, 5)
|
|
expect(world.y).toBeCloseTo(camY, 5)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('consistency between screenToWorld and projectNpcLabel', () => {
|
|
const rect = { left: 0, top: 0, width: 1920, height: 1080 }
|
|
const canvas = { width: 3840, height: 2160 } // DPR 2.0
|
|
const camX = 1000
|
|
const camY = 1000
|
|
const zoom = 1.75
|
|
|
|
const npc = { x: 1150, y: 920, frame: null }
|
|
const projection = projectNpcLabel({
|
|
npc,
|
|
camX,
|
|
camY,
|
|
cameraZoom: zoom,
|
|
canvas,
|
|
rect,
|
|
markerHeight: 0,
|
|
})
|
|
|
|
// unproject the projected dom position
|
|
const worldBack = screenToWorld({
|
|
clientX: projection.domX,
|
|
clientY: projection.domY + 2, // projectNpcLabel offsets domY by -2
|
|
rect,
|
|
canvas,
|
|
camX,
|
|
camY,
|
|
cameraZoom: zoom,
|
|
})
|
|
|
|
expect(worldBack.x).toBeCloseTo(npc.x, 3)
|
|
expect(worldBack.y).toBeCloseTo(npc.y, 3)
|
|
})
|
|
})
|
|
|
|
describe('2. Ground Click-to-Move: navTarget, player movement, arrival threshold <= 8px', () => {
|
|
it('clicking on ground sets navTarget and produces normalized directional movement vector', () => {
|
|
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
|
|
|
// Click ground at world (1100, 1000) -> 100px East
|
|
// Camera is centered at (player.x, player.y - 16) = (1000, 984)
|
|
// client center is (960, 540). (1100, 1000) corresponds to clientX = 960 + 100 = 1060, clientY = 540 + 16 = 556
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 1060,
|
|
clientY: 556,
|
|
button: 0,
|
|
}))
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
|
|
clientX: 1060,
|
|
clientY: 556,
|
|
button: 0,
|
|
}))
|
|
|
|
expect(scene.controller.navTarget).not.toBeNull()
|
|
expect(scene.controller.navTarget!.x).toBeCloseTo(1100, 1)
|
|
expect(scene.controller.navTarget!.y).toBeCloseTo(1000, 1)
|
|
|
|
const tickResult = scene.controller.tick()
|
|
expect(tickResult.movement.x).toBeGreaterThan(0.99)
|
|
expect(tickResult.movement.y).toBeCloseTo(0, 2)
|
|
expect(tickResult.attacking).toBe(false)
|
|
})
|
|
|
|
it('strict arrival threshold <= 8px boundary check', () => {
|
|
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
|
scene.controller.navTarget = { x: 1000, y: 1000 }
|
|
|
|
// Test distance exactly 8.000px: MUST stop and clear navTarget
|
|
scene.controller.navTarget = { x: 1008, y: 1000 }
|
|
let tick = scene.controller.tick()
|
|
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
|
|
// Test distance 7.5px (< 8px): MUST stop
|
|
scene.controller.navTarget = { x: 1000, y: 1007.5 }
|
|
tick = scene.controller.tick()
|
|
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
|
|
// Test distance 8.05px (> 8px): MUST continue moving
|
|
scene.controller.navTarget = { x: 1008.05, y: 1000 }
|
|
tick = scene.controller.tick()
|
|
expect(tick.movement.x).toBeGreaterThan(0)
|
|
expect(scene.controller.navTarget).not.toBeNull()
|
|
})
|
|
|
|
it('continuous drag updates navTarget with cursor coordinates on pointermove', () => {
|
|
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 1060,
|
|
clientY: 556,
|
|
button: 0,
|
|
}))
|
|
expect(scene.controller.isLeftMouseDown).toBe(true)
|
|
|
|
// Move cursor across multiple positions
|
|
const positions = [
|
|
{ clientX: 1100, clientY: 556 },
|
|
{ clientX: 1200, clientY: 600 },
|
|
{ clientX: 900, clientY: 400 },
|
|
]
|
|
|
|
for (const pos of positions) {
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointermove', {
|
|
clientX: pos.clientX,
|
|
clientY: pos.clientY,
|
|
button: 0,
|
|
}))
|
|
const expectedWorld = scene.controller.getPointerWorldCoords(pos.clientX, pos.clientY)
|
|
expect(scene.controller.navTarget!.x).toBeCloseTo(expectedWorld.x, 3)
|
|
expect(scene.controller.navTarget!.y).toBeCloseTo(expectedWorld.y, 3)
|
|
}
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', {
|
|
clientX: 900,
|
|
clientY: 400,
|
|
button: 0,
|
|
}))
|
|
expect(scene.controller.isLeftMouseDown).toBe(false)
|
|
})
|
|
|
|
it('running mode applies 1.5x runFactor to movement vector', () => {
|
|
const scene = createChallengerScene({ playerPos: { x: 1000, y: 1000 } })
|
|
scene.controller.navTarget = { x: 1100, y: 1000 } // 100px East
|
|
|
|
// Walk mode
|
|
scene.hudManager.isRunning = false
|
|
const walkTick = scene.controller.tick()
|
|
expect(walkTick.movement.x).toBeCloseTo(1.0, 3)
|
|
|
|
// Run mode
|
|
scene.hudManager.isRunning = true
|
|
const runTick = scene.controller.tick()
|
|
expect(runTick.movement.x).toBeCloseTo(1.5, 3)
|
|
})
|
|
})
|
|
|
|
describe('3. NPC interaction: immediate dialogue (<= 80px) vs pathing and arrival (> 80px)', () => {
|
|
it('immediate dialogue when clicking NPC at distance <= 80px without moving', () => {
|
|
// NPC placed at (1050, 1000) -> distance 50px <= 80px
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
npcs: [{ id: 1, name: 'Akara', x: 1050, y: 1000 }],
|
|
})
|
|
|
|
// Click near Akara's feet (1050, 1000)
|
|
const clientCoords = {
|
|
clientX: 960 + 50,
|
|
clientY: 540 + 16,
|
|
}
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: clientCoords.clientX,
|
|
clientY: clientCoords.clientY,
|
|
button: 0,
|
|
}))
|
|
|
|
// Must trigger dialogue immediately
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.status.textContent).toContain('Akara')
|
|
expect(scene.engine.dialog).not.toBeNull()
|
|
expect(scene.engine.world.player.facing).toBe(6) // Facing East
|
|
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
|
})
|
|
|
|
it('paths towards NPC when distance > 80px and opens dialogue on arrival', () => {
|
|
// NPC placed at (1300, 1000) -> distance 300px > 80px
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
npcs: [{ id: 2, name: 'Warriv', x: 1300, y: 1000 }],
|
|
})
|
|
|
|
const clientCoords = {
|
|
clientX: 960 + 300,
|
|
clientY: 540 + 16,
|
|
}
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: clientCoords.clientX,
|
|
clientY: clientCoords.clientY,
|
|
button: 0,
|
|
}))
|
|
|
|
// Must set pendingInteraction and navTarget
|
|
expect(scene.controller.pendingInteraction).toEqual({
|
|
kind: 'npc',
|
|
npc: expect.objectContaining({ x: 1300, y: 1000 }),
|
|
})
|
|
expect(scene.controller.navTarget).toEqual({ x: 1300, y: 1000 })
|
|
|
|
// Tick moves towards Warriv
|
|
const tick1 = scene.controller.tick()
|
|
expect(tick1.movement.x).toBeGreaterThan(0)
|
|
expect(scene.engine.dialog).toBeNull()
|
|
|
|
// Step player closer to distance 80px: x = 1220 (dist = 80px)
|
|
scene.engine.world.player.x = 1220
|
|
const arrivalTick = scene.controller.tick()
|
|
|
|
expect(arrivalTick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.status.textContent).toContain('Warriv')
|
|
expect(scene.engine.dialog).not.toBeNull()
|
|
expect(scene.engine.world.player.facing).toBe(6) // Facing East
|
|
})
|
|
|
|
it('selects closest NPC when multiple NPCs are nearby', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
npcs: [
|
|
{ id: 1, name: 'Akara', x: 1030, y: 1000 }, // 30px away
|
|
{ id: 2, name: 'Charsi', x: 1060, y: 1000 }, // 60px away
|
|
],
|
|
})
|
|
|
|
// Click on Akara (1030, 1000)
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 30,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
expect(scene.status.textContent).toContain('Akara')
|
|
expect(scene.status.textContent).not.toContain('Charsi')
|
|
})
|
|
})
|
|
|
|
describe('4. Waypoint interaction: immediate activation (<= 80px) vs pathing and arrival (> 80px)', () => {
|
|
it('immediate activation and panel opening when clicking Waypoint at distance <= 80px', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
waypoints: [{ waypointId: 1, x: 1040, y: 1000 }], // 40px away
|
|
})
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 40,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 1)).toBe(true)
|
|
expect(scene.hudManager.leftPanel).toBe('waypoint')
|
|
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
|
})
|
|
|
|
it('paths towards Waypoint when distance > 80px and activates upon arrival', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
waypoints: [{ waypointId: 2, x: 1400, y: 1000 }], // 400px away
|
|
})
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 400,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
expect(scene.controller.pendingInteraction?.kind).toBe('waypoint')
|
|
expect(scene.controller.navTarget).not.toBeNull()
|
|
expect(scene.hudManager.leftPanel).toBe('none')
|
|
|
|
// Tick moves towards waypoint
|
|
const walkTick = scene.controller.tick()
|
|
expect(walkTick.movement.x).toBeGreaterThan(0)
|
|
|
|
// Move player within 80px: x = 1330 (dist = 70px)
|
|
scene.engine.world.player.x = 1330
|
|
const arriveTick = scene.controller.tick()
|
|
|
|
expect(arriveTick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.waypointNetwork.destinations().some(d => d.waypointId === 2)).toBe(true)
|
|
expect(scene.hudManager.leftPanel).toBe('waypoint')
|
|
})
|
|
})
|
|
|
|
describe('5. Monster targeting: melee reach 52px vs spell range, approaches and attacks', () => {
|
|
it('melee attack (skill 0): attacks immediately at distance <= 52px without pathing', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 0,
|
|
monsters: [{ id: 'mon1', name: 'Fallen', x: 1040, y: 1000, hp: 50 }], // 40px <= 52px
|
|
})
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 40,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
// Attacks immediately
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(50)
|
|
expect(scene.engine.metrics.playerHits).toBe(1)
|
|
expect(scene.engine.world.player.cooldown).toBe(12)
|
|
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
|
})
|
|
|
|
it('melee attack (skill 0): paths to monster when distance > 52px and attacks on arrival', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 0,
|
|
monsters: [{ id: 'mon2', name: 'Zombie', x: 1200, y: 1000, hp: 80 }], // 200px > 52px
|
|
})
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 200,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
expect(scene.controller.pendingInteraction?.kind).toBe('monster')
|
|
expect(scene.controller.navTarget).toEqual({ x: 1200, y: 1000 })
|
|
expect(scene.engine.world.monsters[0]!.hp).toBe(80)
|
|
|
|
// Move player into reach: x = 1160 (dist = 40px <= 52px)
|
|
scene.engine.world.player.x = 1160
|
|
scene.engine.world.player.cooldown = 0
|
|
const attackTick = scene.controller.tick()
|
|
|
|
expect(attackTick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.engine.world.monsters[0]!.hp).toBeLessThan(80)
|
|
expect(scene.engine.metrics.playerHits).toBe(1)
|
|
})
|
|
|
|
it('spell attack (skill 47 Fire Ball): casts from range (<= 450px) without pathing', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 47, // Fire Ball, range 450
|
|
mana: 100,
|
|
monsters: [{ id: 'mon3', name: 'Gargoyle', x: 1350, y: 1000, hp: 120 }], // 350px <= 450px
|
|
})
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 350,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
// Casts immediately from 350px away: spawns projectile, deducts mana, no pathing
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.engine.projectiles.length).toBe(1)
|
|
expect(scene.engine.projectiles[0]!.skillId).toBe('47')
|
|
expect(scene.hudManager.mana).toBeLessThan(100)
|
|
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
|
})
|
|
|
|
it('spell attack: paths when monster is beyond spell range (> 450px)', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 47,
|
|
mana: 100,
|
|
monsters: [{ id: 'mon4', name: 'Skeleton', x: 1600, y: 1000, hp: 100 }], // 600px > 450px
|
|
})
|
|
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 600,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
expect(scene.controller.pendingInteraction?.kind).toBe('monster')
|
|
expect(scene.controller.navTarget).toEqual({ x: 1600, y: 1000 })
|
|
expect(scene.engine.projectiles.length).toBe(0)
|
|
|
|
// Move player into spell range: x = 1200 (dist = 400px <= 450px)
|
|
scene.engine.world.player.x = 1200
|
|
scene.engine.world.player.cooldown = 0
|
|
const castTick = scene.controller.tick()
|
|
|
|
expect(castTick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.engine.projectiles.length).toBe(1)
|
|
})
|
|
|
|
it('dead monsters cannot be targeted or clicked', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
monsters: [{ id: 'monDead', name: 'Dead Zombie', x: 1040, y: 1000, hp: 0 }],
|
|
})
|
|
scene.engine.world.monsters[0]!.state = 'dead'
|
|
|
|
// Click directly on dead monster
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 40,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
|
|
// Should be treated as ground click, NOT monster interaction
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
expect(scene.controller.navTarget).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('6. Shift + Left Click: forced {0,0} velocity, face cursor, execute attack without pathing', () => {
|
|
it('Shift + Left Click forces velocity {0, 0}, faces cursor, and attacks without pathing', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 47, // Fire Ball
|
|
mana: 100,
|
|
})
|
|
|
|
// Hold Shift
|
|
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
|
expect(scene.input.shiftHeld).toBe(true)
|
|
|
|
// Click ground far away (1500, 1000) -> East
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 500,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
shiftKey: true,
|
|
}))
|
|
|
|
// Navigation target must remain null (stand still)
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.controller.pendingInteraction).toBeNull()
|
|
|
|
// Player faces cursor (East = 6)
|
|
expect(scene.engine.world.player.facing).toBe(6)
|
|
|
|
// Spell cast executed
|
|
expect(scene.engine.projectiles.length).toBe(1)
|
|
expect(scene.engine.projectiles[0]!.vx).toBeGreaterThan(0)
|
|
expect(scene.hudManager.mana).toBeLessThan(100)
|
|
|
|
// Tick movement must be strictly {0, 0}
|
|
const tick = scene.controller.tick()
|
|
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
|
})
|
|
|
|
it('Shift + Left Click with Melee Attack (skill 0) swings in place without moving', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 0, // Melee attack
|
|
})
|
|
|
|
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftRight', key: 'Shift' }))
|
|
expect(scene.input.shiftHeld).toBe(true)
|
|
|
|
// Click North-West
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 - 200,
|
|
clientY: 540 - 200,
|
|
button: 0,
|
|
shiftKey: true,
|
|
}))
|
|
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
expect(scene.engine.world.player.facing).toBe(3) // North-West
|
|
expect(scene.engine.world.player.cooldown).toBe(12)
|
|
expect(scene.engine.world.player.swingTicks).toBe(6)
|
|
expect(scene.controller.tick().movement).toEqual({ x: 0, y: 0 })
|
|
})
|
|
|
|
it('continuous hold: player stays rooted in place across multiple frames while casting', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 47,
|
|
mana: 300,
|
|
})
|
|
|
|
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 200,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
shiftKey: true,
|
|
}))
|
|
|
|
// Run 30 consecutive ticks
|
|
for (let t = 0; t < 30; t++) {
|
|
scene.engine.world.player.cooldown = 0 // simulate cooldown completing
|
|
const tick = scene.controller.tick()
|
|
expect(tick.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
}
|
|
|
|
// Multiple projectiles fired while player never moved
|
|
expect(scene.engine.projectiles.length).toBeGreaterThan(1)
|
|
expect(scene.engine.world.player.x).toBe(1000)
|
|
expect(scene.engine.world.player.y).toBe(1000)
|
|
})
|
|
|
|
it('pressing Shift while navTarget is active must immediately arrest movement to {0,0}', () => {
|
|
const scene = createChallengerScene({
|
|
playerPos: { x: 1000, y: 1000 },
|
|
leftSkillId: 47,
|
|
})
|
|
|
|
// Ground click to start walking East
|
|
scene.canvas.dispatchEvent(new SyntheticPointerEvent('pointerdown', {
|
|
clientX: 960 + 200,
|
|
clientY: 540 + 16,
|
|
button: 0,
|
|
}))
|
|
expect(scene.controller.navTarget).not.toBeNull()
|
|
|
|
// First tick moves player East
|
|
const tick1 = scene.controller.tick()
|
|
expect(tick1.movement.x).toBeGreaterThan(0)
|
|
|
|
// Player presses Shift while left mouse is still down
|
|
scene.win.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'ShiftLeft', key: 'Shift' }))
|
|
expect(scene.input.shiftHeld).toBe(true)
|
|
|
|
// Next tick with Shift held MUST arrest movement to {0, 0}
|
|
const tickWithShift = scene.controller.tick()
|
|
expect(tickWithShift.movement).toEqual({ x: 0, y: 0 })
|
|
expect(scene.controller.navTarget).toBeNull()
|
|
})
|
|
})
|
|
})
|