diablo2-web/tests/challenger-m2-stress.test.ts

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