134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
/**
|
|
* Authentic PATHTYPE_STRAIGHT Local Pathfinding Tests (Issue #588).
|
|
*
|
|
* Verifies Diablo II v1.13c ground truth (D2Common/src/Path/PathMisc.cpp:583-620):
|
|
* - Target coordinate delta > 100 sub-tiles is rejected
|
|
* - Straight line of sight raycast proceeds directly to target when unobstructed
|
|
* - When blocked AND Euclidean distance squared <= 18^2 (324), invokes local A* around obstacle
|
|
* - When blocked AND distance squared > 324, does not run A*, stops directly at the obstacle
|
|
* - Diagonal wall corner pinching is strictly prevented
|
|
* - Maximum path length is capped at MAXPATHLEN (78)
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
findPathLocal,
|
|
hasLineOfSightLocal,
|
|
MAX_ASTAR_OBSTACLE_DISTANCE_SQ,
|
|
MAX_STRAIGHT_DISTANCE_DELTA,
|
|
MAXPATHLEN,
|
|
type SubTileWalkabilityGrid,
|
|
} from '../../src/client/motion/pathfind.ts'
|
|
|
|
function createGrid(
|
|
width: number,
|
|
height: number,
|
|
isBlocked: (x: number, y: number) => boolean = () => false,
|
|
): SubTileWalkabilityGrid {
|
|
const walkable = new Uint8Array(width * height)
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
walkable[y * width + x] = isBlocked(x, y) ? 0 : 1
|
|
}
|
|
}
|
|
return { subWidth: width, subHeight: height, walkable }
|
|
}
|
|
|
|
describe('PATHTYPE_STRAIGHT Pathfinding (Issue #588)', () => {
|
|
it('exports authentic 1.13c constants', () => {
|
|
expect(MAXPATHLEN).toBe(78)
|
|
expect(MAX_STRAIGHT_DISTANCE_DELTA).toBe(100)
|
|
expect(MAX_ASTAR_OBSTACLE_DISTANCE_SQ).toBe(324)
|
|
})
|
|
|
|
it('rejects target coordinate delta > 100 sub-tiles', () => {
|
|
const grid = createGrid(200, 200)
|
|
|
|
// Delta X > 100 (from 10 to 111 = delta 101)
|
|
const resX = findPathLocal(grid, 10, 10, 111, 10)
|
|
expect(resX.path).toEqual([])
|
|
expect(resX.reachedGoal).toBe(false)
|
|
|
|
// Delta Y > 100 (from 10 to 111 = delta 101)
|
|
const resY = findPathLocal(grid, 10, 10, 10, 111)
|
|
expect(resY.path).toEqual([])
|
|
expect(resY.reachedGoal).toBe(false)
|
|
|
|
// Delta = 100 is allowed
|
|
const resValid = findPathLocal(grid, 10, 10, 110, 10)
|
|
expect(resValid.reachedGoal).toBe(true)
|
|
expect(resValid.path.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('proceeds along straight line of sight when unobstructed', () => {
|
|
const grid = createGrid(50, 50)
|
|
const res = findPathLocal(grid, 5, 5, 20, 15, { smooth: true })
|
|
|
|
expect(res.reachedGoal).toBe(true)
|
|
expect(res.path).toEqual([
|
|
{ x: 5, y: 5 },
|
|
{ x: 20, y: 15 },
|
|
])
|
|
// Line of sight confirmed
|
|
expect(hasLineOfSightLocal(grid, 5, 5, 20, 15)).toBe(true)
|
|
})
|
|
|
|
it('invokes local A* when blocked and distance squared <= 324 (18 subtiles)', () => {
|
|
// 30x30 grid with a wall blocking the direct line at x=10
|
|
// Start (5, 10), Goal (15, 10): dx = 10, dy = 0, distSq = 100 <= 324
|
|
// Wall at x=10 from y=8 to y=12
|
|
const grid = createGrid(30, 30, (x, y) => x === 10 && y >= 8 && y <= 12)
|
|
|
|
// Direct ray is blocked by wall
|
|
expect(hasLineOfSightLocal(grid, 5, 10, 15, 10)).toBe(false)
|
|
|
|
const res = findPathLocal(grid, 5, 10, 15, 10)
|
|
expect(res.reachedGoal).toBe(true)
|
|
// A* successfully navigated around the obstacle
|
|
expect(res.path.length).toBeGreaterThan(1)
|
|
expect(res.path[0]).toEqual({ x: 5, y: 10 })
|
|
expect(res.path[res.path.length - 1]).toEqual({ x: 15, y: 10 })
|
|
|
|
// Verify none of the waypoints intersect the wall
|
|
for (const pt of res.path) {
|
|
expect(pt.x === 10 && pt.y >= 8 && pt.y <= 12).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('stops directly at obstacle without invoking A* when distance squared > 324', () => {
|
|
// 50x50 grid with a wall blocking the line at x=20
|
|
// Start (0, 10), Goal (30, 10): dx = 30, dy = 0, distSq = 900 > 324
|
|
// Wall at x=20 from y=0 to y=20
|
|
const grid = createGrid(50, 50, (x, y) => x === 20 && y >= 0 && y <= 20)
|
|
|
|
const res = findPathLocal(grid, 0, 10, 30, 10)
|
|
// 1.13c parity: does NOT reach goal, stops at the wall obstacle along straight ray
|
|
expect(res.reachedGoal).toBe(false)
|
|
expect(res.expandedNodes).toBe(1) // raycast only, A* not expanded
|
|
expect(res.path.length).toBeGreaterThan(0)
|
|
// The last point in the path is right in front of the wall (x=19)
|
|
const lastPoint = res.path[res.path.length - 1]!
|
|
expect(lastPoint.x).toBe(19)
|
|
expect(lastPoint.y).toBe(10)
|
|
})
|
|
|
|
it('prevents diagonal corner cutting through pinch walls', () => {
|
|
// Diagonal pinch: wall at (1, 0) and (0, 1)
|
|
// Transition (0, 0) to (1, 1) should be blocked
|
|
const grid = createGrid(5, 5, (x, y) => (x === 1 && y === 0) || (x === 0 && y === 1))
|
|
|
|
expect(hasLineOfSightLocal(grid, 0, 0, 1, 1)).toBe(false)
|
|
const res = findPathLocal(grid, 0, 0, 1, 1)
|
|
// Because (1,0) and (0,1) are blocked, unit cannot cut corner to (1,1)
|
|
expect(res.reachedGoal).toBe(false)
|
|
})
|
|
|
|
it('caps path length at MAXPATHLEN (78)', () => {
|
|
const grid = createGrid(100, 100)
|
|
// Un-smoothed long path along straight line of 90 cells
|
|
const res = findPathLocal(grid, 5, 5, 95, 5, { smooth: false })
|
|
expect(res.path.length).toBeLessThanOrEqual(MAXPATHLEN)
|
|
expect(res.rawPath.length).toBeLessThanOrEqual(MAXPATHLEN)
|
|
})
|
|
})
|