417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
/**
|
|
* Unit & Parity Tests for Keyboard Conflict Resolution & Hotkey Harmonization (Requirement R1 / R5)
|
|
* Verifies ghost interaction decoupling, combat safety, and potion drinking invariants.
|
|
*/
|
|
|
|
import * as fs from 'node:fs'
|
|
import * as path from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { HudManager, type HudCommandSink } from '../src/client/ui/hud-manager.ts'
|
|
import { seedDemoHud } from './fixtures/hud-demo-state.ts'
|
|
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/server/engine/engine.ts'
|
|
import { WaypointNetwork } from '../src/common/world/portal.ts'
|
|
import type { SceneEntrance, SceneWarp } from '../src/common/world/level-links.ts'
|
|
import { subTileToScene } from '../src/common/world/d2map.ts'
|
|
import { getLevelNames } from '../src/common/world/level-names-zh.ts'
|
|
import { screenToWorld, facingOf, TELEKINESIS_RANGE_PX, getWarpLabelWorldPos, getEntranceLabelWorldPos, hitTestWarp, hitTestEntrance, isPlayerWithinLinkRange, resolveObstacleAwareSteerTarget, sceneToSubTile } from '../src/common/world/scene-links-nav.ts'
|
|
import { PASSIVE_SKILL_IDS, isPassiveSkill, getSkillRange, getSkillMana } from '../src/common/skills/weapon-combat-meta.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('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('requests belt potion use for columns 0..3 via keys 1..4 (server-authoritative) without resetting active skills', () => {
|
|
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
|
|
const hud = new HudManager(canvas, {
|
|
onToggleAutomap: () => {},
|
|
onWaypointTeleport: () => {},
|
|
})
|
|
hud['bindEvents']()
|
|
|
|
// Explicit legacy demo state: Sorceress, 16-potion belt, vitals
|
|
seedDemoHud(hud)
|
|
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)
|
|
|
|
// No engine and no command sink: keys 1..4 do nothing locally (no client-side healing / belt mutation)
|
|
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: 'Digit1', key: '1' }))
|
|
expect(hud.hp).toBe(400)
|
|
expect(hud.belt.countTotalPotions()).toBe(16)
|
|
|
|
// With a server command sink attached, keys 1..4 only emit BeltUse requests for columns 0..3
|
|
const beltRequests: number[] = []
|
|
hud.commandSink = {
|
|
useBeltSlot: (col: number) => {
|
|
beltRequests.push(col)
|
|
return true
|
|
},
|
|
} as unknown as HudCommandSink
|
|
|
|
for (const [i, key] of ['1', '2', '3', '4'].entries()) {
|
|
mockWin.dispatchEvent(new SyntheticKeyboardEvent('keydown', { code: `Digit${key}`, key }))
|
|
expect(beltRequests).toEqual([0, 1, 2, 3].slice(0, i + 1))
|
|
// Active skills are never reset by belt hotkeys
|
|
expect(hud.hotkeys.leftSkillId).toBe(47)
|
|
expect(hud.hotkeys.rightSkillId).toBe(64)
|
|
expect(hud.state.leftSkillId).toBe(47)
|
|
expect(hud.state.rightSkillId).toBe(64)
|
|
}
|
|
|
|
// Local state is unchanged until the server answers (HudModel.syncFromWorld)
|
|
expect(hud.hp).toBe(400)
|
|
expect(hud.mana).toBe(200)
|
|
expect(hud.belt.countTotalPotions()).toBe(16)
|
|
hud.commandSink = null
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Labels', () => {
|
|
|
|
it('verifies all 136 baked map packs (192 SceneWarp and 60 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(136)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 192 total warps (179 with LvlWarp.txt bounding boxes + 13 zero-box warps)
|
|
// across the 136 canonical DRLG scenes (DRLG_ACT_VARIANTS = 1, GAME_SEED = 0x5eed_0100).
|
|
expect(warpCount).toBe(192)
|
|
expect(nonZeroBoxWarps).toBe(179)
|
|
expect(zeroBoxWarps).toBe(13)
|
|
// 60 seamless entrances across the 136 canonical DRLG scenes (62 seamless edges in
|
|
// world-graph.json minus the 2 mutually-exclusive inactive Act 3 Jungle seam edges).
|
|
expect(entranceCount).toBe(60)
|
|
})
|
|
|
|
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('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)
|
|
})
|
|
})
|
|
|