Fix remote entity velocity, player FRW kinematics, PATHTYPE_STRAIGHT, and gateModeBase (Closes #586, #587, #588, #598, #600)
This commit is contained in:
parent
3c7586b500
commit
17fd1f1b6c
|
|
@ -23,6 +23,7 @@ import {
|
||||||
targetUnitTurnTowardPoint,
|
targetUnitTurnTowardPoint,
|
||||||
type UnitTurnState,
|
type UnitTurnState,
|
||||||
} from '../../common/units/direction.ts'
|
} from '../../common/units/direction.ts'
|
||||||
|
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
|
||||||
import {
|
import {
|
||||||
D2_BASE_RUN_SUBTILES_PER_SEC,
|
D2_BASE_RUN_SUBTILES_PER_SEC,
|
||||||
D2_BASE_WALK_SUBTILES_PER_SEC,
|
D2_BASE_WALK_SUBTILES_PER_SEC,
|
||||||
|
|
@ -35,6 +36,7 @@ const DEFAULT_REMOTE_SNAP_THRESHOLD = 5.0
|
||||||
export interface RemoteEntityMotionState {
|
export interface RemoteEntityMotionState {
|
||||||
readonly unitType: number
|
readonly unitType: number
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
|
readonly classId?: number | undefined
|
||||||
readonly x: number
|
readonly x: number
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly targetX: number
|
readonly targetX: number
|
||||||
|
|
@ -55,13 +57,17 @@ export type RemoteEntityState = RemoteEntityMotionState
|
||||||
export interface RemoteMoveCommand {
|
export interface RemoteMoveCommand {
|
||||||
readonly unitType: number
|
readonly unitType: number
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
readonly currentX?: number
|
readonly classId?: number | undefined
|
||||||
readonly currentY?: number
|
readonly currentX?: number | undefined
|
||||||
|
readonly currentY?: number | undefined
|
||||||
readonly targetX: number
|
readonly targetX: number
|
||||||
readonly targetY: number
|
readonly targetY: number
|
||||||
readonly running?: boolean
|
readonly running?: boolean | undefined
|
||||||
readonly moveType?: number
|
readonly moveType?: number | undefined
|
||||||
readonly speedSubTilesPerSec?: number
|
readonly speedSubTilesPerSec?: number | undefined
|
||||||
|
readonly velocityPercent?: number | undefined
|
||||||
|
readonly nVelocity?: number | undefined
|
||||||
|
readonly pathType?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteStopCommand {
|
export interface RemoteStopCommand {
|
||||||
|
|
@ -69,12 +75,13 @@ export interface RemoteStopCommand {
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
readonly x: number
|
readonly x: number
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly hardSnapThreshold?: number
|
readonly hardSnapThreshold?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MutableRemoteEntityMotion {
|
interface MutableRemoteEntityMotion {
|
||||||
unitType: number
|
unitType: number
|
||||||
unitId: number
|
unitId: number
|
||||||
|
classId?: number | undefined
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
targetX: number
|
targetX: number
|
||||||
|
|
@ -93,6 +100,7 @@ function toState(state: MutableRemoteEntityMotion): RemoteEntityMotionState {
|
||||||
return {
|
return {
|
||||||
unitType: state.unitType,
|
unitType: state.unitType,
|
||||||
unitId: state.unitId,
|
unitId: state.unitId,
|
||||||
|
...(state.classId !== undefined ? { classId: state.classId } : {}),
|
||||||
x: state.x,
|
x: state.x,
|
||||||
y: state.y,
|
y: state.y,
|
||||||
targetX: state.targetX,
|
targetX: state.targetX,
|
||||||
|
|
@ -107,15 +115,22 @@ function toState(state: MutableRemoteEntityMotion): RemoteEntityMotionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte into `{ running, speedSubTilesPerSec }`.
|
* Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte and unit stats into `{ running, speedSubTilesPerSec }`.
|
||||||
* In D2 1.13c:
|
*
|
||||||
* - `0x17` (23) or bit `0x10` / `0x02` indicates running
|
* 1.13c Ground Truth:
|
||||||
* - `0x01` indicates walking
|
* - Base velocity: players use CharStats.txt WalkVelocity (6) / RunVelocity (9);
|
||||||
|
* monsters use MonStats.txt Velocity / Run (D2Common Units.cpp:1288-1299).
|
||||||
|
* - S2C 0x67 / 0x68 transmits nVelocity (STAT_VELOCITYPERCENT total).
|
||||||
|
* - Speed floor is 25% of base speed (Units.cpp:1678).
|
||||||
*/
|
*/
|
||||||
export function resolveRemoteMoveSpeed(options: {
|
export function resolveRemoteMoveSpeed(options: {
|
||||||
readonly running?: boolean
|
readonly running?: boolean | undefined
|
||||||
readonly moveType?: number
|
readonly moveType?: number | undefined
|
||||||
readonly speedSubTilesPerSec?: number
|
readonly speedSubTilesPerSec?: number | undefined
|
||||||
|
readonly unitType?: number | undefined
|
||||||
|
readonly classId?: number | undefined
|
||||||
|
readonly velocityPercent?: number | undefined
|
||||||
|
readonly nVelocity?: number | undefined
|
||||||
}): { readonly running: boolean; readonly speedSubTilesPerSec: number } {
|
}): { readonly running: boolean; readonly speedSubTilesPerSec: number } {
|
||||||
const running =
|
const running =
|
||||||
options.running ??
|
options.running ??
|
||||||
|
|
@ -127,9 +142,25 @@ export function resolveRemoteMoveSpeed(options: {
|
||||||
if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) {
|
if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) {
|
||||||
return { running, speedSubTilesPerSec: options.speedSubTilesPerSec }
|
return { running, speedSubTilesPerSec: options.speedSubTilesPerSec }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const velPct = options.velocityPercent ?? options.nVelocity
|
||||||
|
|
||||||
|
// If monster / NPC (unitType === 1) with known classId:
|
||||||
|
if (options.classId !== undefined) {
|
||||||
|
const speed = computeMonsterMoveSpeedSubTilesPerSec(options.classId, running, velPct)
|
||||||
|
return { running, speedSubTilesPerSec: speed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote player or generic unit:
|
||||||
|
let baseSpeed = running ? D2_BASE_RUN_SUBTILES_PER_SEC : D2_BASE_WALK_SUBTILES_PER_SEC
|
||||||
|
if (velPct !== undefined && velPct !== 0) {
|
||||||
|
// 25% minimum speed floor per 1.13c ground truth (Units.cpp:1678)
|
||||||
|
const effectivePct = Math.max(25, velPct)
|
||||||
|
baseSpeed = (D2_BASE_WALK_SUBTILES_PER_SEC * effectivePct) / 100
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
running,
|
running,
|
||||||
speedSubTilesPerSec: running ? D2_BASE_RUN_SUBTILES_PER_SEC : D2_BASE_WALK_SUBTILES_PER_SEC,
|
speedSubTilesPerSec: baseSpeed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +182,8 @@ export class RemoteEntityInterpolator {
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
readonly x: number
|
readonly x: number
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly dir64?: number
|
readonly dir64?: number | undefined
|
||||||
|
readonly classId?: number | undefined
|
||||||
}): RemoteEntityMotionState {
|
}): RemoteEntityMotionState {
|
||||||
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
|
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
|
||||||
const key = entityKey(params.unitType, params.unitId)
|
const key = entityKey(params.unitType, params.unitId)
|
||||||
|
|
@ -162,14 +194,21 @@ export class RemoteEntityInterpolator {
|
||||||
: previous
|
: previous
|
||||||
? { ...previous.turnState }
|
? { ...previous.turnState }
|
||||||
: createUnitTurnState(0)
|
: createUnitTurnState(0)
|
||||||
|
const classId = params.classId ?? previous?.classId
|
||||||
|
const { speedSubTilesPerSec } = resolveRemoteMoveSpeed({
|
||||||
|
running: false,
|
||||||
|
unitType: params.unitType,
|
||||||
|
classId,
|
||||||
|
})
|
||||||
const state: MutableRemoteEntityMotion = {
|
const state: MutableRemoteEntityMotion = {
|
||||||
unitType: params.unitType,
|
unitType: params.unitType,
|
||||||
unitId: params.unitId >>> 0,
|
unitId: params.unitId >>> 0,
|
||||||
|
classId,
|
||||||
x: params.x,
|
x: params.x,
|
||||||
y: params.y,
|
y: params.y,
|
||||||
targetX: params.x,
|
targetX: params.x,
|
||||||
targetY: params.y,
|
targetY: params.y,
|
||||||
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
|
speedSubTilesPerSec,
|
||||||
moving: false,
|
moving: false,
|
||||||
running: false,
|
running: false,
|
||||||
turnState,
|
turnState,
|
||||||
|
|
@ -183,8 +222,12 @@ export class RemoteEntityInterpolator {
|
||||||
*/
|
*/
|
||||||
startMove(cmd: RemoteMoveCommand): RemoteEntityMotionState {
|
startMove(cmd: RemoteMoveCommand): RemoteEntityMotionState {
|
||||||
const key = entityKey(cmd.unitType, cmd.unitId)
|
const key = entityKey(cmd.unitType, cmd.unitId)
|
||||||
const { running, speedSubTilesPerSec } = resolveRemoteMoveSpeed(cmd)
|
|
||||||
let state = this.entities.get(key)
|
let state = this.entities.get(key)
|
||||||
|
const classId = cmd.classId ?? state?.classId
|
||||||
|
const { running, speedSubTilesPerSec } = resolveRemoteMoveSpeed({
|
||||||
|
...cmd,
|
||||||
|
classId,
|
||||||
|
})
|
||||||
|
|
||||||
const startX = cmd.currentX ?? state?.x ?? cmd.targetX
|
const startX = cmd.currentX ?? state?.x ?? cmd.targetX
|
||||||
const startY = cmd.currentY ?? state?.y ?? cmd.targetY
|
const startY = cmd.currentY ?? state?.y ?? cmd.targetY
|
||||||
|
|
@ -193,6 +236,7 @@ export class RemoteEntityInterpolator {
|
||||||
state = {
|
state = {
|
||||||
unitType: cmd.unitType,
|
unitType: cmd.unitType,
|
||||||
unitId: cmd.unitId >>> 0,
|
unitId: cmd.unitId >>> 0,
|
||||||
|
classId,
|
||||||
x: startX,
|
x: startX,
|
||||||
y: startY,
|
y: startY,
|
||||||
targetX: cmd.targetX,
|
targetX: cmd.targetX,
|
||||||
|
|
@ -203,11 +247,16 @@ export class RemoteEntityInterpolator {
|
||||||
turnState: createUnitTurnState(0),
|
turnState: createUnitTurnState(0),
|
||||||
}
|
}
|
||||||
this.entities.set(key, state)
|
this.entities.set(key, state)
|
||||||
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
} else {
|
||||||
const drift = Math.hypot(cmd.currentX - state.x, cmd.currentY - state.y)
|
if (classId !== undefined) {
|
||||||
if (drift > this.snapThreshold) {
|
state.classId = classId
|
||||||
state.x = cmd.currentX
|
}
|
||||||
state.y = cmd.currentY
|
if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
||||||
|
const drift = Math.hypot(cmd.currentX - state.x, cmd.currentY - state.y)
|
||||||
|
if (drift > this.snapThreshold) {
|
||||||
|
state.x = cmd.currentX
|
||||||
|
state.y = cmd.currentY
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,14 +271,7 @@ export class RemoteEntityInterpolator {
|
||||||
if (dist > 1e-4) {
|
if (dist > 1e-4) {
|
||||||
state.moving = true
|
state.moving = true
|
||||||
targetUnitTurnTowardPoint(state.turnState, state.x, state.y, state.targetX, state.targetY, false)
|
targetUnitTurnTowardPoint(state.turnState, state.x, state.y, state.targetX, state.targetY, false)
|
||||||
// If visual position lagged behind server start, slightly boost speed to catch up smoothly
|
// 1.13c Parity (Issue #598): Units advance cleanly along waypoints at authentic velocity without synthetic 1.5x acceleration
|
||||||
if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
|
||||||
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
|
|
||||||
if (serverSegDist > 0.25 && dist > serverSegDist) {
|
|
||||||
const catchupFactor = Math.min(1.5, dist / serverSegDist)
|
|
||||||
state.speedSubTilesPerSec = speedSubTilesPerSec * catchupFactor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
state.x = state.targetX
|
state.x = state.targetX
|
||||||
state.y = state.targetY
|
state.y = state.targetY
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@ export interface PathfindResult {
|
||||||
readonly expandedNodes: number
|
readonly expandedNodes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maximum path length in 1.13c (D2Common/src/Path/PathMisc.cpp, MAXPATHLEN = 78). */
|
||||||
|
export const MAXPATHLEN = 78
|
||||||
|
/** Maximum straight-line coordinate delta allowed in PATHTYPE_STRAIGHT (100 sub-tiles). */
|
||||||
|
export const MAX_STRAIGHT_DISTANCE_DELTA = 100
|
||||||
|
/** Maximum Euclidean distance squared (18^2 = 324) to invoke local A* when straight line is blocked. */
|
||||||
|
export const MAX_ASTAR_OBSTACLE_DISTANCE_SQ = 324
|
||||||
|
|
||||||
const SQRT2 = Math.SQRT2
|
const SQRT2 = Math.SQRT2
|
||||||
const OCTILE_DIAG_DIFF = SQRT2 - 1
|
const OCTILE_DIAG_DIFF = SQRT2 - 1
|
||||||
|
|
||||||
|
|
@ -164,6 +171,82 @@ export function hasLineOfSightWorld(
|
||||||
return hasLineOfSightLocal(grid, worldX0 - ox, worldY0 - oy, worldX1 - ox, worldY1 - oy)
|
return hasLineOfSightLocal(grid, worldX0 - ox, worldY0 - oy, worldX1 - ox, worldY1 - oy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trace a straight supercover Bresenham ray from (x0, y0) toward (x1, y1).
|
||||||
|
* Returns the sequence of walked sub-tiles up to the first obstacle or the target.
|
||||||
|
*/
|
||||||
|
export function traceStraightRayLocal(
|
||||||
|
grid: SubTileWalkabilityGrid,
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
): { readonly points: SubTilePoint[]; readonly blocked: boolean } {
|
||||||
|
let cx = Math.floor(x0)
|
||||||
|
let cy = Math.floor(y0)
|
||||||
|
const tx = Math.floor(x1)
|
||||||
|
const ty = Math.floor(y1)
|
||||||
|
|
||||||
|
if (!isCellWalkableLocal(grid, cx, cy)) {
|
||||||
|
return { points: [], blocked: true }
|
||||||
|
}
|
||||||
|
const points: SubTilePoint[] = [{ x: cx, y: cy }]
|
||||||
|
if (cx === tx && cy === ty) {
|
||||||
|
return { points, blocked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = Math.abs(tx - cx)
|
||||||
|
const dy = Math.abs(ty - cy)
|
||||||
|
const stepX = cx < tx ? 1 : -1
|
||||||
|
const stepY = cy < ty ? 1 : -1
|
||||||
|
|
||||||
|
let err = dx - dy
|
||||||
|
const n = dx + dy
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const e2 = err * 2
|
||||||
|
const moveX = e2 > -dy
|
||||||
|
const moveY = e2 < dx
|
||||||
|
|
||||||
|
let nextX = cx
|
||||||
|
let nextY = cy
|
||||||
|
|
||||||
|
if (moveX && moveY) {
|
||||||
|
// Diagonal transition: require both cardinal neighbors to prevent corner clipping
|
||||||
|
if (
|
||||||
|
!isCellWalkableLocal(grid, cx + stepX, cy) ||
|
||||||
|
!isCellWalkableLocal(grid, cx, cy + stepY)
|
||||||
|
) {
|
||||||
|
return { points, blocked: true }
|
||||||
|
}
|
||||||
|
nextX += stepX
|
||||||
|
nextY += stepY
|
||||||
|
err -= dy
|
||||||
|
err += dx
|
||||||
|
i++
|
||||||
|
} else if (moveX) {
|
||||||
|
nextX += stepX
|
||||||
|
err -= dy
|
||||||
|
} else if (moveY) {
|
||||||
|
nextY += stepY
|
||||||
|
err += dx
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCellWalkableLocal(grid, nextX, nextY)) {
|
||||||
|
return { points, blocked: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
cx = nextX
|
||||||
|
cy = nextY
|
||||||
|
points.push({ x: cx, y: cy })
|
||||||
|
if (cx === tx && cy === ty) {
|
||||||
|
return { points, blocked: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { points, blocked: !isCellWalkableLocal(grid, tx, ty) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the nearest walkable local sub-tile around `(targetX, targetY)` within `maxRadius`.
|
* Find the nearest walkable local sub-tile around `(targetX, targetY)` within `maxRadius`.
|
||||||
* Ties are broken by Euclidean distance toward `(fromX, fromY)` when provided.
|
* Ties are broken by Euclidean distance toward `(fromX, fromY)` when provided.
|
||||||
|
|
@ -301,7 +384,15 @@ class MinHeap {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run 8-directional A* pathfinding in level-local sub-tile coordinates.
|
* Run PATHTYPE_STRAIGHT pathfinding in level-local sub-tile coordinates (D2Common/src/Path/PathMisc.cpp:583-620).
|
||||||
|
*
|
||||||
|
* 1.13c Ground Truth:
|
||||||
|
* - Target delta > 100 sub-tiles is rejected.
|
||||||
|
* - First traces a straight line toward the goal.
|
||||||
|
* - If unobstructed, returns the straight line.
|
||||||
|
* - If blocked and Euclidean distance squared <= 18^2 (324), invokes local A* around the obstacle.
|
||||||
|
* - If distance squared > 324 or A* fails, stops at the obstacle.
|
||||||
|
* - All paths are capped at MAXPATHLEN (78).
|
||||||
*/
|
*/
|
||||||
export function findPathLocal(
|
export function findPathLocal(
|
||||||
grid: SubTileWalkabilityGrid,
|
grid: SubTileWalkabilityGrid,
|
||||||
|
|
@ -311,6 +402,12 @@ export function findPathLocal(
|
||||||
goalY: number,
|
goalY: number,
|
||||||
options: PathfindOptions = {},
|
options: PathfindOptions = {},
|
||||||
): PathfindResult {
|
): PathfindResult {
|
||||||
|
const deltaX = Math.abs(goalX - startX)
|
||||||
|
const deltaY = Math.abs(goalY - startY)
|
||||||
|
if (deltaX > MAX_STRAIGHT_DISTANCE_DELTA || deltaY > MAX_STRAIGHT_DISTANCE_DELTA) {
|
||||||
|
return { path: [], rawPath: [], reachedGoal: false, expandedNodes: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
const maxExpanded = options.maxExpandedNodes ?? 16384
|
const maxExpanded = options.maxExpandedNodes ?? 16384
|
||||||
const smooth = options.smooth ?? true
|
const smooth = options.smooth ?? true
|
||||||
const nearestRadius = options.nearestTargetRadius ?? 10
|
const nearestRadius = options.nearestTargetRadius ?? 10
|
||||||
|
|
@ -353,18 +450,37 @@ export function findPathLocal(
|
||||||
return { path: [pt], rawPath: [pt], reachedGoal: true, expandedNodes: 1 }
|
return { path: [pt], rawPath: [pt], reachedGoal: true, expandedNodes: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast path: direct line-of-sight between start and goal
|
// PATHTYPE_STRAIGHT: trace straight ray
|
||||||
if (smooth && hasLineOfSightLocal(grid, sx, sy, gx, gy)) {
|
const ray = traceStraightRayLocal(grid, sx, sy, gx, gy)
|
||||||
const startPt = { x: sx, y: sy }
|
if (!ray.blocked) {
|
||||||
const goalPt = { x: gx, y: gy }
|
const rawPath = ray.points.slice(0, MAXPATHLEN)
|
||||||
|
const finalPath = smooth ? [{ x: sx, y: sy }, { x: gx, y: gy }] : rawPath
|
||||||
return {
|
return {
|
||||||
path: [startPt, goalPt],
|
path: finalPath,
|
||||||
rawPath: [startPt, goalPt],
|
rawPath,
|
||||||
reachedGoal: true,
|
reachedGoal: true,
|
||||||
expandedNodes: 1,
|
expandedNodes: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obstructed ray: check distance squared
|
||||||
|
const distSq = (gx - sx) * (gx - sx) + (gy - sy) * (gy - sy)
|
||||||
|
if (distSq > MAX_ASTAR_OBSTACLE_DISTANCE_SQ) {
|
||||||
|
// Distance > 18 sub-tiles: 1.13c does not invoke A*, stops at obstacle
|
||||||
|
const rawPath = ray.points.slice(0, MAXPATHLEN)
|
||||||
|
const finalPath =
|
||||||
|
smooth && rawPath.length > 2
|
||||||
|
? [{ x: sx, y: sy }, rawPath[rawPath.length - 1]!]
|
||||||
|
: rawPath
|
||||||
|
return {
|
||||||
|
path: finalPath,
|
||||||
|
rawPath,
|
||||||
|
reachedGoal: false,
|
||||||
|
expandedNodes: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance squared <= 324 (18^2): invoke local A* to navigate around obstacle
|
||||||
const totalCells = w * h
|
const totalCells = w * h
|
||||||
const gScore = new Float32Array(totalCells)
|
const gScore = new Float32Array(totalCells)
|
||||||
gScore.fill(Infinity)
|
gScore.fill(Infinity)
|
||||||
|
|
@ -436,8 +552,19 @@ export function findPathLocal(
|
||||||
}
|
}
|
||||||
|
|
||||||
const endIdx = reachedGoal ? goalIdx : allowPartial ? bestIdx : -1
|
const endIdx = reachedGoal ? goalIdx : allowPartial ? bestIdx : -1
|
||||||
if (endIdx < 0) {
|
if (endIdx < 0 || (!reachedGoal && endIdx === startIdx)) {
|
||||||
return { path: [], rawPath: [], reachedGoal: false, expandedNodes }
|
// If A* failed or could not leave start, stop at the straight ray obstacle
|
||||||
|
const rawPath = ray.points.slice(0, MAXPATHLEN)
|
||||||
|
const finalPath =
|
||||||
|
smooth && rawPath.length > 2
|
||||||
|
? [{ x: sx, y: sy }, rawPath[rawPath.length - 1]!]
|
||||||
|
: rawPath
|
||||||
|
return {
|
||||||
|
path: finalPath,
|
||||||
|
rawPath,
|
||||||
|
reachedGoal: false,
|
||||||
|
expandedNodes,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPath: SubTilePoint[] = []
|
const rawPath: SubTilePoint[] = []
|
||||||
|
|
@ -451,10 +578,11 @@ export function findPathLocal(
|
||||||
}
|
}
|
||||||
rawPath.reverse()
|
rawPath.reverse()
|
||||||
|
|
||||||
const finalPath = smooth ? smoothPathLocal(grid, rawPath) : rawPath
|
const cappedRaw = rawPath.slice(0, MAXPATHLEN)
|
||||||
|
const finalPath = smooth ? smoothPathLocal(grid, cappedRaw) : cappedRaw
|
||||||
return {
|
return {
|
||||||
path: finalPath,
|
path: finalPath.slice(0, MAXPATHLEN),
|
||||||
rawPath,
|
rawPath: cappedRaw,
|
||||||
reachedGoal,
|
reachedGoal,
|
||||||
expandedNodes,
|
expandedNodes,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ export interface PredictorConfig {
|
||||||
readonly walkSpeedSubTilesPerSec?: number
|
readonly walkSpeedSubTilesPerSec?: number
|
||||||
readonly runSpeedSubTilesPerSec?: number
|
readonly runSpeedSubTilesPerSec?: number
|
||||||
readonly fasterRunWalkPercent?: number
|
readonly fasterRunWalkPercent?: number
|
||||||
|
readonly skillVelocityPercent?: number
|
||||||
|
readonly armorSpeedPenaltyPercent?: number
|
||||||
|
readonly slowPercent?: number
|
||||||
|
readonly staminaExhausted?: boolean
|
||||||
|
readonly plrMode?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which D2Client 0x6FADA1B0 caller is checking an authoritative position. */
|
/** Which D2Client 0x6FADA1B0 caller is checking an authoritative position. */
|
||||||
|
|
@ -87,6 +92,10 @@ export interface ServerPositionCheck {
|
||||||
* player stand in TN instead of NU.
|
* player stand in TN instead of NU.
|
||||||
*/
|
*/
|
||||||
readonly inTown: boolean
|
readonly inTown: boolean
|
||||||
|
/**
|
||||||
|
* Optional authoritative player mode (PlrMode enum / integer from D2Client).
|
||||||
|
*/
|
||||||
|
readonly plrMode?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerPositionVerdictReason =
|
export type ServerPositionVerdictReason =
|
||||||
|
|
@ -153,6 +162,11 @@ export class LocalMovementPredictor {
|
||||||
private walkSpeed: number
|
private walkSpeed: number
|
||||||
private runSpeed: number
|
private runSpeed: number
|
||||||
private fasterRunWalkPercent: number
|
private fasterRunWalkPercent: number
|
||||||
|
private skillVelocityPercent: number
|
||||||
|
private armorSpeedPenaltyPercent: number
|
||||||
|
private slowPercent: number
|
||||||
|
private staminaExhausted: boolean
|
||||||
|
private plrMode?: number | undefined
|
||||||
private running = true
|
private running = true
|
||||||
private readonly turnState: UnitTurnState
|
private readonly turnState: UnitTurnState
|
||||||
private waypoints: SubTilePoint[] = []
|
private waypoints: SubTilePoint[] = []
|
||||||
|
|
@ -164,19 +178,41 @@ export class LocalMovementPredictor {
|
||||||
this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC
|
this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC
|
||||||
this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC
|
this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC
|
||||||
this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0
|
this.fasterRunWalkPercent = config.fasterRunWalkPercent ?? 0
|
||||||
|
this.skillVelocityPercent = config.skillVelocityPercent ?? 0
|
||||||
|
this.armorSpeedPenaltyPercent = config.armorSpeedPenaltyPercent ?? 0
|
||||||
|
this.slowPercent = config.slowPercent ?? 0
|
||||||
|
this.staminaExhausted = config.staminaExhausted ?? false
|
||||||
|
this.plrMode = config.plrMode
|
||||||
this.turnState = createUnitTurnState(config.initialDir64 ?? 0)
|
this.turnState = createUnitTurnState(config.initialDir64 ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effective movement speed in sub-tiles per second.
|
* Effective movement speed in sub-tiles per second.
|
||||||
|
*
|
||||||
|
* Authentic D2 1.13c kinematics (D2Common/src/Units/Units.cpp:1288-1299, 1672-1693):
|
||||||
|
* - Base walk velocity: 6 yards/s (9.0 sub-tiles/s), run velocity: 9 yards/s (13.5 sub-tiles/s)
|
||||||
|
* - Running bonus: 100 * RunVelocity / WalkVelocity - 100 = 50% (disabled when stamina is exhausted)
|
||||||
|
* - Item FRW: diminishing returns formula `eff = v > 0 ? Math.floor(150 * v / (150 + v)) : v`
|
||||||
|
* - Skill FRW (e.g. Burst of Speed, Vigor): adds directly without diminishing returns
|
||||||
|
* - Armor speed penalty (e.g. Medium armor -5%, Heavy armor -10%): subtracts directly
|
||||||
|
* - Slow/chill: subtracts directly from velocity percent
|
||||||
|
* - Speed floor: minimum 25% of base walk velocity (Units.cpp:1678)
|
||||||
*/
|
*/
|
||||||
getEffectiveSpeedSubTilesPerSec(): number {
|
getEffectiveSpeedSubTilesPerSec(): number {
|
||||||
const base = this.running ? this.runSpeed : this.walkSpeed
|
|
||||||
if (this.fasterRunWalkPercent === 0) return base
|
|
||||||
// D2 diminishing returns on FRW: effectiveFRW = floor(150 * frw / (150 + frw))
|
|
||||||
const frw = this.fasterRunWalkPercent
|
const frw = this.fasterRunWalkPercent
|
||||||
const effFrw = frw > 0 ? Math.floor((150 * frw) / (150 + frw)) : frw
|
const effItemFrw = frw > 0 ? Math.floor((150 * frw) / (150 + frw)) : frw
|
||||||
return Math.max(1.5, base + D2_BASE_WALK_SUBTILES_PER_SEC * (effFrw / 100))
|
|
||||||
|
const isRunning = this.running && !this.staminaExhausted
|
||||||
|
const runBonus = isRunning
|
||||||
|
? (this.walkSpeed > 0 ? Math.round((100 * this.runSpeed) / this.walkSpeed) - 100 : 50)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const armorPenalty = Math.abs(this.armorSpeedPenaltyPercent)
|
||||||
|
const totalPercent =
|
||||||
|
100 + effItemFrw + runBonus + this.skillVelocityPercent - armorPenalty - this.slowPercent
|
||||||
|
const effectivePercent = Math.max(25, totalPercent)
|
||||||
|
|
||||||
|
return (this.walkSpeed * effectivePercent) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
setRunning(running: boolean): void {
|
setRunning(running: boolean): void {
|
||||||
|
|
@ -187,6 +223,74 @@ export class LocalMovementPredictor {
|
||||||
this.fasterRunWalkPercent = frwPercent
|
this.fasterRunWalkPercent = frwPercent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFasterRunWalkPercent(): number {
|
||||||
|
return this.fasterRunWalkPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
setSkillVelocityPercent(skillVelocityPercent: number): void {
|
||||||
|
this.skillVelocityPercent = skillVelocityPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
getSkillVelocityPercent(): number {
|
||||||
|
return this.skillVelocityPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
setArmorSpeedPenaltyPercent(armorSpeedPenaltyPercent: number): void {
|
||||||
|
this.armorSpeedPenaltyPercent = armorSpeedPenaltyPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
getArmorSpeedPenaltyPercent(): number {
|
||||||
|
return this.armorSpeedPenaltyPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
setSlowPercent(slowPercent: number): void {
|
||||||
|
this.slowPercent = slowPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
getSlowPercent(): number {
|
||||||
|
return this.slowPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
setStaminaExhausted(exhausted: boolean): void {
|
||||||
|
this.staminaExhausted = exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
isStaminaExhausted(): boolean {
|
||||||
|
return this.staminaExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayerMode(plrMode: number | undefined): void {
|
||||||
|
this.plrMode = plrMode
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerMode(): number | undefined {
|
||||||
|
return this.plrMode
|
||||||
|
}
|
||||||
|
|
||||||
|
setVelocityModifiers(modifiers: {
|
||||||
|
readonly fasterRunWalkPercent?: number
|
||||||
|
readonly skillVelocityPercent?: number
|
||||||
|
readonly armorSpeedPenaltyPercent?: number
|
||||||
|
readonly slowPercent?: number
|
||||||
|
readonly staminaExhausted?: boolean
|
||||||
|
}): void {
|
||||||
|
if (modifiers.fasterRunWalkPercent !== undefined) {
|
||||||
|
this.fasterRunWalkPercent = modifiers.fasterRunWalkPercent
|
||||||
|
}
|
||||||
|
if (modifiers.skillVelocityPercent !== undefined) {
|
||||||
|
this.skillVelocityPercent = modifiers.skillVelocityPercent
|
||||||
|
}
|
||||||
|
if (modifiers.armorSpeedPenaltyPercent !== undefined) {
|
||||||
|
this.armorSpeedPenaltyPercent = modifiers.armorSpeedPenaltyPercent
|
||||||
|
}
|
||||||
|
if (modifiers.slowPercent !== undefined) {
|
||||||
|
this.slowPercent = modifiers.slowPercent
|
||||||
|
}
|
||||||
|
if (modifiers.staminaExhausted !== undefined) {
|
||||||
|
this.staminaExhausted = modifiers.staminaExhausted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Place the player at `(x, y)` and drop its path, keeping the facing. This is also the
|
* Place the player at `(x, y)` and drop its path, keeping the facing. This is also the
|
||||||
* `0x15 ReassignPlayer` behaviour: D2Client 0x6FB5F640 → 0x6FB57E20 repositions the unit
|
* `0x15 ReassignPlayer` behaviour: D2Client 0x6FB5F640 → 0x6FB57E20 repositions the unit
|
||||||
|
|
@ -323,13 +427,13 @@ export class LocalMovementPredictor {
|
||||||
const segDist = Math.hypot(dx, dy)
|
const segDist = Math.hypot(dx, dy)
|
||||||
const segTimeMs = speed > 0 ? (segDist / speed) * 1000 : 0
|
const segTimeMs = speed > 0 ? (segDist / speed) * 1000 : 0
|
||||||
|
|
||||||
if (remainingTimeMs >= segTimeMs) {
|
if (remainingTimeMs >= segTimeMs - 1e-4) {
|
||||||
if (segTimeMs > 0) {
|
if (segTimeMs > 0) {
|
||||||
advanceUnitTurnByDtMs(this.turnState, segTimeMs)
|
advanceUnitTurnByDtMs(this.turnState, segTimeMs)
|
||||||
}
|
}
|
||||||
this.logicalX = target.x
|
this.logicalX = target.x
|
||||||
this.logicalY = target.y
|
this.logicalY = target.y
|
||||||
remainingTimeMs -= segTimeMs
|
remainingTimeMs = Math.max(0, remainingTimeMs - segTimeMs)
|
||||||
this.waypointIndex++
|
this.waypointIndex++
|
||||||
this.faceCurrentWaypoint()
|
this.faceCurrentWaypoint()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -391,10 +495,13 @@ export class LocalMovementPredictor {
|
||||||
return { action: 'none', reason: 'no-position', clientX, clientY, threshold: 0 }
|
return { action: 'none', reason: 'no-position', clientX, clientY, threshold: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mode = check.plrMode ?? this.plrMode
|
||||||
|
const modeBase =
|
||||||
|
mode !== undefined ? this.gateModeBase(mode) : this.gateModeBase(check.inTown)
|
||||||
const threshold =
|
const threshold =
|
||||||
source.kind === 'player-stop'
|
source.kind === 'player-stop'
|
||||||
? GATE_FLAG1_THRESHOLD
|
? GATE_FLAG1_THRESHOLD
|
||||||
: this.gateModeBase(check.inTown) + ((Math.trunc(check.rttMs) + 50) >>> 7)
|
: modeBase + ((Math.trunc(check.rttMs) + 50) >>> 7)
|
||||||
const exceeded =
|
const exceeded =
|
||||||
Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold
|
Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold
|
||||||
if (!exceeded) {
|
if (!exceeded) {
|
||||||
|
|
@ -455,11 +562,25 @@ export class LocalMovementPredictor {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The local player's mode as D2Client picks it — NU / TN standing (TN in town), WL / TW
|
* The local player's mode tolerance base for D2Client 0x6FADA1B0:
|
||||||
* walking, RN running — mapped to the 0x6FADA1B0 tolerance base: NU 3, RN 7, others 5.
|
* - PlrMode 1 (NU) -> 3
|
||||||
|
* - PlrMode 3 (RN) -> 7
|
||||||
|
* - All other modes (A1, A2, SC, GH, BL, WL, TN, TW, etc.) -> 5.
|
||||||
|
*
|
||||||
|
* If a numeric mode is not provided:
|
||||||
|
* - Moving: running -> 7 (RN), walking -> 5 (WL)
|
||||||
|
* - Idle/Standing: inTown -> 5 (TN), outside town -> 3 (NU)
|
||||||
*/
|
*/
|
||||||
private gateModeBase(inTown: boolean): number {
|
gateModeBase(modeOrInTown?: number | boolean, inTown = false): number {
|
||||||
if (this.waypointIndex >= this.waypoints.length) return inTown ? 5 : 3
|
if (typeof modeOrInTown === 'number') {
|
||||||
return this.running ? 7 : 5
|
if (modeOrInTown === 1) return 3 // PlrMode 1 (NU)
|
||||||
|
if (modeOrInTown === 3) return 7 // PlrMode 3 (RN)
|
||||||
|
return 5 // All other modes
|
||||||
|
}
|
||||||
|
const town = typeof modeOrInTown === 'boolean' ? modeOrInTown : inTown
|
||||||
|
if (this.waypointIndex < this.waypoints.length) {
|
||||||
|
return this.running ? 7 : 5
|
||||||
|
}
|
||||||
|
return town ? 5 : 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,12 +149,12 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent {
|
||||||
const moveType = r.u8()
|
const moveType = r.u8()
|
||||||
const targetX = r.u16LE()
|
const targetX = r.u16LE()
|
||||||
const targetY = r.u16LE()
|
const targetY = r.u16LE()
|
||||||
let pathType = 0
|
let pathType: number | undefined
|
||||||
let velocityPercent = 0
|
let velocityPercent: number | undefined
|
||||||
if (r.remaining >= 5) {
|
if (r.remaining >= 5) {
|
||||||
r.u16LE() // unk0x0A
|
r.u16LE() // unk0x0A, unk0x0B
|
||||||
pathType = r.u8()
|
pathType = r.u8() // 0x0C nPathType
|
||||||
velocityPercent = r.i16LE()
|
velocityPercent = r.i16LE() // 0x0D..0x0E nVelocity
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: 'UnitMove',
|
type: 'UnitMove',
|
||||||
|
|
@ -162,8 +162,8 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent {
|
||||||
id,
|
id,
|
||||||
mode: resolveMoveMode(moveType),
|
mode: resolveMoveMode(moveType),
|
||||||
to: { x: targetX, y: targetY },
|
to: { x: targetX, y: targetY },
|
||||||
...(pathType !== 0 ? { pathType } : {}),
|
...(pathType !== undefined ? { pathType } : {}),
|
||||||
...(velocityPercent !== 0 ? { velocityPercent } : {}),
|
...(velocityPercent !== undefined ? { velocityPercent } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,12 +177,12 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent {
|
||||||
const currentY = r.u16LE()
|
const currentY = r.u16LE()
|
||||||
const targetType = clampUnitType(r.u8())
|
const targetType = clampUnitType(r.u8())
|
||||||
const targetId = r.u32LE()
|
const targetId = r.u32LE()
|
||||||
let pathType = 0
|
let pathType: number | undefined
|
||||||
let velocityPercent = 0
|
let velocityPercent: number | undefined
|
||||||
if (r.remaining >= 5) {
|
if (r.remaining >= 5) {
|
||||||
r.u16LE() // unk0x0F
|
r.u16LE() // unk0x0F, unk0x10
|
||||||
pathType = r.u8()
|
pathType = r.u8() // 0x11 nPathType
|
||||||
velocityPercent = r.i16LE()
|
velocityPercent = r.i16LE() // 0x12..0x13 nVelocity
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: 'UnitMove',
|
type: 'UnitMove',
|
||||||
|
|
@ -192,8 +192,8 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent {
|
||||||
from: { x: currentX, y: currentY },
|
from: { x: currentX, y: currentY },
|
||||||
to: { x: currentX, y: currentY },
|
to: { x: currentX, y: currentY },
|
||||||
target: { unitType: targetType, id: targetId },
|
target: { unitType: targetType, id: targetId },
|
||||||
...(pathType !== 0 ? { pathType } : {}),
|
...(pathType !== undefined ? { pathType } : {}),
|
||||||
...(velocityPercent !== 0 ? { velocityPercent } : {}),
|
...(velocityPercent !== undefined ? { velocityPercent } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
/**
|
||||||
|
* Batch 5 Phase 2 Comprehensive Integration Test Suite (Issues #586, #587, #588, #598, #600).
|
||||||
|
*
|
||||||
|
* Validates complete 1.13c parity for:
|
||||||
|
* 1. Remote Entity Movement Velocity & S2C 0x67/0x68 nVelocity / nPathType (#586)
|
||||||
|
* 2. Local Player Predicted Velocity with FRW Kinematics, Stamina, and Chill (#587)
|
||||||
|
* 3. Authentic PATHTYPE_STRAIGHT Local Pathing (#588)
|
||||||
|
* 4. Remote Unit Movement Interpolation Alignment without Artificial Accelerations (#598)
|
||||||
|
* 5. Position Gate gateModeBase Aligned with Authentic PlrMode Values (#600)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
RemoteEntityInterpolator,
|
||||||
|
resolveRemoteMoveSpeed,
|
||||||
|
} from '../../src/client/motion/interpolate.ts'
|
||||||
|
import {
|
||||||
|
findPathLocal,
|
||||||
|
hasLineOfSightLocal,
|
||||||
|
MAX_ASTAR_OBSTACLE_DISTANCE_SQ,
|
||||||
|
MAX_STRAIGHT_DISTANCE_DELTA,
|
||||||
|
MAXPATHLEN,
|
||||||
|
type SubTileWalkabilityGrid,
|
||||||
|
} from '../../src/client/motion/pathfind.ts'
|
||||||
|
import {
|
||||||
|
D2_BASE_RUN_SUBTILES_PER_SEC,
|
||||||
|
D2_BASE_WALK_SUBTILES_PER_SEC,
|
||||||
|
LocalMovementPredictor,
|
||||||
|
} from '../../src/client/motion/predictor.ts'
|
||||||
|
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts'
|
||||||
|
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||||||
|
import {
|
||||||
|
decode0x67NpcMove,
|
||||||
|
decode0x68NpcMoveToTarget,
|
||||||
|
} from '../../src/netproto/d2gs/s2c/movement.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('Batch 5 Phase 2: Motion Kinematics, Velocity & Pathing Integration', () => {
|
||||||
|
describe('Issue #586 & #598: Remote entity kinematics & MonStats', () => {
|
||||||
|
it('processes S2C 0x67 NPCMove packet and interpolates monster at exact MonStats speed', () => {
|
||||||
|
// Decode S2C 0x67 for Zombie (classId 19, unitId 50) moving to (100, 100) with nVelocity = 100
|
||||||
|
const bw = new ByteWriter(16)
|
||||||
|
bw.u8(0x67)
|
||||||
|
bw.u32LE(50) // unitId
|
||||||
|
bw.u8(0x01) // walk
|
||||||
|
bw.u16LE(100) // targetX
|
||||||
|
bw.u16LE(100) // targetY
|
||||||
|
bw.u16LE(0) // unk
|
||||||
|
bw.u8(4) // nPathType
|
||||||
|
bw.i16LE(100) // nVelocity
|
||||||
|
bw.u8(0)
|
||||||
|
|
||||||
|
const ev = decode0x67NpcMove(bw.toUint8Array())
|
||||||
|
expect(ev.type).toBe('UnitMove')
|
||||||
|
if (ev.type !== 'UnitMove') return
|
||||||
|
expect(ev.pathType).toBe(4)
|
||||||
|
expect(ev.velocityPercent).toBe(100)
|
||||||
|
|
||||||
|
const interpolator = new RemoteEntityInterpolator()
|
||||||
|
interpolator.spawnOrSnap({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 50,
|
||||||
|
classId: 19,
|
||||||
|
x: 90,
|
||||||
|
y: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveState = interpolator.startMove({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 50,
|
||||||
|
classId: 19,
|
||||||
|
targetX: ev.to.x,
|
||||||
|
targetY: ev.to.y,
|
||||||
|
velocityPercent: ev.velocityPercent,
|
||||||
|
pathType: ev.pathType,
|
||||||
|
})
|
||||||
|
|
||||||
|
const expectedSpeed = computeMonsterMoveSpeedSubTilesPerSec(19, false, 100)
|
||||||
|
expect(moveState.speedSubTilesPerSec).toBe(expectedSpeed)
|
||||||
|
|
||||||
|
// Step 500ms
|
||||||
|
interpolator.update(500)
|
||||||
|
const afterStep = interpolator.getEntityState(1, 50)!
|
||||||
|
expect(afterStep.moving).toBe(true)
|
||||||
|
expect(afterStep.x).toBeCloseTo(90 + (expectedSpeed * 500) / 1000, 4)
|
||||||
|
|
||||||
|
// Server stop packet settles unit cleanly at authoritative stop position (#598)
|
||||||
|
const stopped = interpolator.stopEntity({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 50,
|
||||||
|
x: afterStep.x,
|
||||||
|
y: 100,
|
||||||
|
})
|
||||||
|
expect(stopped.moving).toBe(false)
|
||||||
|
expect(stopped.x).toBe(afterStep.x)
|
||||||
|
expect(stopped.y).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #587 & #600: Player FRW kinematics & gateModeBase', () => {
|
||||||
|
it('simulates player running with FRW boots, Vigor aura, medium armor, and stamina exhaustion', () => {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 })
|
||||||
|
predictor.setRunning(true)
|
||||||
|
|
||||||
|
// Equip 40% FRW boots (Item FRW -> floor(150*40/190) = floor(6000/190) = 31%)
|
||||||
|
// Vigor level 1 aura (+32% skill FRW)
|
||||||
|
// Medium armor (-5% speed penalty)
|
||||||
|
// Chilled by cold damage (-30% slow)
|
||||||
|
predictor.setVelocityModifiers({
|
||||||
|
fasterRunWalkPercent: 40,
|
||||||
|
skillVelocityPercent: 32,
|
||||||
|
armorSpeedPenaltyPercent: 5,
|
||||||
|
slowPercent: 30,
|
||||||
|
staminaExhausted: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Running total = 100 + 31 (item) + 50 (run) + 32 (skill) - 5 (armor) - 30 (chill) = 178%
|
||||||
|
// Speed = 9.0 * 1.78 = 16.02
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(16.02, 5)
|
||||||
|
|
||||||
|
// Stamina exhausted -> running bonus (50%) removed
|
||||||
|
predictor.setStaminaExhausted(true)
|
||||||
|
// Walking total = 100 + 31 + 32 - 5 - 30 = 128%
|
||||||
|
// Speed = 9.0 * 1.28 = 11.52
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(11.52, 5)
|
||||||
|
|
||||||
|
// Severe slow tests 25% minimum speed floor: 9.0 * 0.25 = 2.25
|
||||||
|
predictor.setSlowPercent(150)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(2.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies position gate tolerance with authentic PlrMode mapping (#600)', () => {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 2000, initialY: 2000 })
|
||||||
|
|
||||||
|
// PlrMode 1 (NU - standing outside town): base tolerance is 3
|
||||||
|
const checkNu = predictor.verifyServerPosition({
|
||||||
|
x: 2003,
|
||||||
|
y: 2000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
plrMode: 1,
|
||||||
|
})
|
||||||
|
expect(checkNu.threshold).toBe(3)
|
||||||
|
expect(checkNu.action).toBe('none')
|
||||||
|
|
||||||
|
// 4 sub-tiles drift exceeds tolerance 3
|
||||||
|
const checkNuExceeded = predictor.verifyServerPosition({
|
||||||
|
x: 2004,
|
||||||
|
y: 2000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
plrMode: 1,
|
||||||
|
})
|
||||||
|
expect(checkNuExceeded.action).toBe('request-resync')
|
||||||
|
|
||||||
|
// PlrMode 3 (RN - running): base tolerance is 7
|
||||||
|
const checkRn = predictor.verifyServerPosition({
|
||||||
|
x: 2007,
|
||||||
|
y: 2000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
plrMode: 3,
|
||||||
|
})
|
||||||
|
expect(checkRn.threshold).toBe(7)
|
||||||
|
expect(checkRn.action).toBe('none')
|
||||||
|
|
||||||
|
// PlrMode 8 (A2 - attacking) / other modes: base tolerance is 5
|
||||||
|
const checkCast = predictor.verifyServerPosition({
|
||||||
|
x: 2005,
|
||||||
|
y: 2000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
plrMode: 8,
|
||||||
|
})
|
||||||
|
expect(checkCast.threshold).toBe(5)
|
||||||
|
expect(checkCast.action).toBe('none')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #588: Authentic PATHTYPE_STRAIGHT and predictor execution', () => {
|
||||||
|
it('generates straight line path and steps predictor along it', () => {
|
||||||
|
const grid = createGrid(50, 50)
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 10, initialY: 10 })
|
||||||
|
predictor.setRunning(false) // 9.0 sub-tiles/sec
|
||||||
|
|
||||||
|
// Path from (10, 10) to (19, 10) -> distance 9 sub-tiles
|
||||||
|
const pathResult = findPathLocal(grid, 10, 10, 19, 10)
|
||||||
|
expect(pathResult.reachedGoal).toBe(true)
|
||||||
|
|
||||||
|
predictor.setPath(pathResult.path, false)
|
||||||
|
expect(predictor.getSnapshot().moving).toBe(true)
|
||||||
|
|
||||||
|
// At 9.0 sub-tiles/sec, stepping 1 second (1000ms = 25 ticks) completes the 9-subtile segment
|
||||||
|
predictor.stepTicks(25)
|
||||||
|
const snap = predictor.getSnapshot()
|
||||||
|
expect(snap.logicalX).toBeCloseTo(19, 4)
|
||||||
|
expect(snap.logicalY).toBeCloseTo(10, 4)
|
||||||
|
expect(snap.moving).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bypasses local obstacles within 18 subtiles and stops at far obstacles', () => {
|
||||||
|
// 1. Within 18 subtiles: start (0, 5), goal (10, 5), obstacle at x=5
|
||||||
|
const gridNear = createGrid(25, 25, (x, y) => x === 5 && y >= 4 && y <= 6)
|
||||||
|
const resNear = findPathLocal(gridNear, 0, 5, 10, 5)
|
||||||
|
expect(resNear.reachedGoal).toBe(true)
|
||||||
|
|
||||||
|
// 2. Beyond 18 subtiles: start (0, 10), goal (25, 10), obstacle at x=15
|
||||||
|
// distSq = 25^2 = 625 > 324
|
||||||
|
const gridFar = createGrid(40, 40, (x, y) => x === 15 && y >= 0 && y <= 20)
|
||||||
|
const resFar = findPathLocal(gridFar, 0, 10, 25, 10)
|
||||||
|
expect(resFar.reachedGoal).toBe(false)
|
||||||
|
expect(resFar.path[resFar.path.length - 1]).toEqual({ x: 14, y: 10 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
/**
|
||||||
|
* Local Player Motion Kinematics & Velocity Predictor Tests (Issues #587, #600).
|
||||||
|
*
|
||||||
|
* Verifies Diablo II v1.13c ground truth:
|
||||||
|
* - Base walk velocity = 6 yards/s (9.0 sub-tiles/s), run = 9 yards/s (13.5 sub-tiles/s)
|
||||||
|
* - Running adds 50% (100 * RunVelocity / WalkVelocity - 100) when stamina is not exhausted
|
||||||
|
* - Item FRW follows diminishing returns: floor(150 * frw / (150 + frw))
|
||||||
|
* - Skill FRW (e.g. Burst of Speed, Vigor) adds linearly without diminishing returns
|
||||||
|
* - Armor speed penalties (-5%, -10%) subtract directly from velocity percent
|
||||||
|
* - Slow / chill subtracts directly from velocity percent
|
||||||
|
* - Minimum speed floor is 25% of base walk velocity (Units.cpp:1678)
|
||||||
|
* - Stamina exhaustion removes the 50% running bonus, forcing walk speed
|
||||||
|
* - gateModeBase matches authentic PlrMode values: 1 (NU) -> 3, 3 (RN) -> 7, others -> 5
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
D2_BASE_RUN_SUBTILES_PER_SEC,
|
||||||
|
D2_BASE_WALK_SUBTILES_PER_SEC,
|
||||||
|
LocalMovementPredictor,
|
||||||
|
} from '../../src/client/motion/predictor.ts'
|
||||||
|
|
||||||
|
describe('LocalMovementPredictor kinematics & FRW (Issue #587)', () => {
|
||||||
|
it('predicts base walk and run velocity without stat bonuses', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
|
||||||
|
// Default running = true
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(D2_BASE_RUN_SUBTILES_PER_SEC) // 13.5
|
||||||
|
|
||||||
|
// Walking
|
||||||
|
predictor.setRunning(false)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(D2_BASE_WALK_SUBTILES_PER_SEC) // 9.0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies D2 diminishing returns formula to item FRW', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
predictor.setRunning(false)
|
||||||
|
|
||||||
|
// With 30% item FRW: floor(150 * 30 / 180) = floor(4500 / 180) = 25%
|
||||||
|
predictor.setFasterRunWalkPercent(30)
|
||||||
|
// 9.0 * (100 + 25) / 100 = 11.25
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(11.25)
|
||||||
|
|
||||||
|
// With 70% item FRW: floor(150 * 70 / 220) = floor(10500 / 220) = 47%
|
||||||
|
predictor.setFasterRunWalkPercent(70)
|
||||||
|
// 9.0 * (100 + 47) / 100 = 13.23
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(13.23, 5)
|
||||||
|
|
||||||
|
// When running with 30% item FRW:
|
||||||
|
// Effective percent = 100 + 25 (item) + 50 (run) = 175%
|
||||||
|
// 9.0 * 1.75 = 15.75
|
||||||
|
predictor.setRunning(true)
|
||||||
|
predictor.setFasterRunWalkPercent(30)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(15.75)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds skill FRW directly without diminishing returns', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
predictor.setRunning(false)
|
||||||
|
|
||||||
|
// Burst of Speed level 5 (+35% skill FRW)
|
||||||
|
predictor.setSkillVelocityPercent(35)
|
||||||
|
// 9.0 * (100 + 35) / 100 = 12.15
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(12.15)
|
||||||
|
|
||||||
|
// Combined 30% item FRW (eff 25%) + 35% skill FRW while running:
|
||||||
|
// Total percent = 100 + 25 (item) + 50 (run) + 35 (skill) = 210%
|
||||||
|
// 9.0 * 2.10 = 18.9
|
||||||
|
predictor.setRunning(true)
|
||||||
|
predictor.setFasterRunWalkPercent(30)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(18.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('subtracts armor speed penalties and chill / slow debuffs', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
predictor.setRunning(false)
|
||||||
|
|
||||||
|
// Heavy armor (-10% penalty)
|
||||||
|
predictor.setArmorSpeedPenaltyPercent(10)
|
||||||
|
// 9.0 * (100 - 10) / 100 = 8.1
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(8.1)
|
||||||
|
|
||||||
|
// Heavy armor + chilled (e.g. 50% slow)
|
||||||
|
predictor.setSlowPercent(50)
|
||||||
|
// 9.0 * (100 - 10 - 50) / 100 = 3.6
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(3.6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces 25% minimum speed floor per 1.13c Units.cpp:1678', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
predictor.setRunning(false)
|
||||||
|
|
||||||
|
// Severe slow (e.g. Clay Golem + Decrepify + Holy Freeze = 90% slow) with heavy armor (-10%)
|
||||||
|
predictor.setArmorSpeedPenaltyPercent(10)
|
||||||
|
predictor.setSlowPercent(90)
|
||||||
|
|
||||||
|
// Total would be 100 - 10 - 90 = 0%, capped at 25% floor
|
||||||
|
// 9.0 * 25 / 100 = 2.25
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(2.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables running speed bonus when stamina is exhausted', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
predictor.setRunning(true)
|
||||||
|
|
||||||
|
// Running with 30% item FRW (eff 25%)
|
||||||
|
predictor.setFasterRunWalkPercent(30)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(15.75) // 9.0 * (100 + 25 + 50) / 100
|
||||||
|
|
||||||
|
// Stamina exhausted: player is forced to walk speed + item FRW
|
||||||
|
predictor.setStaminaExhausted(true)
|
||||||
|
expect(predictor.isStaminaExhausted()).toBe(true)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(11.25) // 9.0 * (100 + 25 + 0) / 100
|
||||||
|
|
||||||
|
// Recover stamina
|
||||||
|
predictor.setStaminaExhausted(false)
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(15.75)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports setVelocityModifiers batch update', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
predictor.setRunning(true)
|
||||||
|
|
||||||
|
predictor.setVelocityModifiers({
|
||||||
|
fasterRunWalkPercent: 30, // eff 25%
|
||||||
|
skillVelocityPercent: 20, // +20%
|
||||||
|
armorSpeedPenaltyPercent: 5, // -5%
|
||||||
|
slowPercent: 10, // -10%
|
||||||
|
staminaExhausted: false, // +50% run
|
||||||
|
})
|
||||||
|
|
||||||
|
// Total = 100 + 25 + 50 + 20 - 5 - 10 = 180%
|
||||||
|
// 9.0 * 1.80 = 16.2
|
||||||
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(16.2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LocalMovementPredictor gateModeBase (Issue #600)', () => {
|
||||||
|
it('maps authentic PlrMode values: 1 (NU) -> 3, 3 (RN) -> 7, others -> 5', () => {
|
||||||
|
const predictor = new LocalMovementPredictor()
|
||||||
|
|
||||||
|
// Mode 1: Neutral / standing
|
||||||
|
expect(predictor.gateModeBase(1)).toBe(3)
|
||||||
|
|
||||||
|
// Mode 3: Running
|
||||||
|
expect(predictor.gateModeBase(3)).toBe(7)
|
||||||
|
|
||||||
|
// Other modes: Walking (2), Get Hit (4), Town Neutral (5), Town Walk (6), Attack 1 (7), Cast (8), etc.
|
||||||
|
expect(predictor.gateModeBase(0)).toBe(5) // Death
|
||||||
|
expect(predictor.gateModeBase(2)).toBe(5) // Walk
|
||||||
|
expect(predictor.gateModeBase(4)).toBe(5) // Get Hit
|
||||||
|
expect(predictor.gateModeBase(5)).toBe(5) // Town Neutral
|
||||||
|
expect(predictor.gateModeBase(6)).toBe(5) // Town Walk
|
||||||
|
expect(predictor.gateModeBase(7)).toBe(5) // Attack 1
|
||||||
|
expect(predictor.gateModeBase(8)).toBe(5) // Attack 2
|
||||||
|
expect(predictor.gateModeBase(9)).toBe(5) // Cast
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses active PlrMode from predictor or ServerPositionCheck when available', () => {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
|
||||||
|
|
||||||
|
// Check with explicit plrMode in ServerPositionCheck
|
||||||
|
const verdictNu = predictor.verifyServerPosition({
|
||||||
|
x: 5003,
|
||||||
|
y: 5000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
plrMode: 1, // NU -> base 3
|
||||||
|
})
|
||||||
|
expect(verdictNu.threshold).toBe(3)
|
||||||
|
expect(verdictNu.action).toBe('none')
|
||||||
|
|
||||||
|
const verdictRn = predictor.verifyServerPosition({
|
||||||
|
x: 5007,
|
||||||
|
y: 5000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
plrMode: 3, // RN -> base 7
|
||||||
|
})
|
||||||
|
expect(verdictRn.threshold).toBe(7)
|
||||||
|
expect(verdictRn.action).toBe('none')
|
||||||
|
|
||||||
|
// Setting mode on predictor
|
||||||
|
predictor.setPlayerMode(4) // Get Hit -> base 5
|
||||||
|
const verdictGh = predictor.verifyServerPosition({
|
||||||
|
x: 5005,
|
||||||
|
y: 5000,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
})
|
||||||
|
expect(verdictGh.threshold).toBe(5)
|
||||||
|
expect(verdictGh.action).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves fallback behavior when plrMode is not provided', () => {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
|
||||||
|
|
||||||
|
// Idle outside town: base 3
|
||||||
|
expect(predictor.gateModeBase(false)).toBe(3)
|
||||||
|
|
||||||
|
// Idle inside town: base 5
|
||||||
|
expect(predictor.gateModeBase(true)).toBe(5)
|
||||||
|
|
||||||
|
// Moving running: base 7
|
||||||
|
predictor.setPath([{ x: 5010, y: 5010 }], true)
|
||||||
|
expect(predictor.gateModeBase(false)).toBe(7)
|
||||||
|
|
||||||
|
// Moving walking: base 5
|
||||||
|
predictor.setPath([{ x: 5010, y: 5010 }], false)
|
||||||
|
expect(predictor.gateModeBase(false)).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
/**
|
||||||
|
* Remote Entity Movement Interpolation & MonStats Velocity Tests (Issues #586, #598).
|
||||||
|
*
|
||||||
|
* Verifies Diablo II v1.13c ground truth:
|
||||||
|
* - Remote monster velocity derives from MonStats.txt (Velocity / Run) via computeMonsterMoveSpeedSubTilesPerSec
|
||||||
|
* - S2C 0x67 / 0x68 decodes nPathType and nVelocity
|
||||||
|
* - S2C nVelocity scales monster / unit speed, retaining the 25% speed floor
|
||||||
|
* - Units advance along waypoints cleanly without synthetic 1.5x catchup acceleration (#598)
|
||||||
|
* - Remote units settle cleanly on authoritative stop packets without sliding glide (#598)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
RemoteEntityInterpolator,
|
||||||
|
resolveRemoteMoveSpeed,
|
||||||
|
} from '../../src/client/motion/interpolate.ts'
|
||||||
|
import {
|
||||||
|
D2_BASE_RUN_SUBTILES_PER_SEC,
|
||||||
|
D2_BASE_WALK_SUBTILES_PER_SEC,
|
||||||
|
} from '../../src/client/motion/predictor.ts'
|
||||||
|
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts'
|
||||||
|
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
||||||
|
import {
|
||||||
|
decode0x67NpcMove,
|
||||||
|
decode0x68NpcMoveToTarget,
|
||||||
|
} from '../../src/netproto/d2gs/s2c/movement.ts'
|
||||||
|
|
||||||
|
describe('S2C Movement Packet Decoders for nPathType & nVelocity (Issue #586)', () => {
|
||||||
|
it('decodes nPathType and nVelocity from 0x67 NpcMove', () => {
|
||||||
|
// 0x67 packet:
|
||||||
|
// [0] 0x67
|
||||||
|
// [1..4] unitId u32LE (e.g. 101)
|
||||||
|
// [5] moveType u8 (e.g. 0x01 walk)
|
||||||
|
// [6..7] targetX u16LE (1234)
|
||||||
|
// [8..9] targetY u16LE (5678)
|
||||||
|
// [10..11] unk0x0A u16LE
|
||||||
|
// [12] nPathType u8 (e.g. 4)
|
||||||
|
// [13..14] nVelocity i16LE (e.g. 75 = 75% speed)
|
||||||
|
// [15] unk u8
|
||||||
|
const w = new ByteWriter(16)
|
||||||
|
w.u8(0x67)
|
||||||
|
w.u32LE(101)
|
||||||
|
w.u8(0x01) // walk
|
||||||
|
w.u16LE(1234)
|
||||||
|
w.u16LE(5678)
|
||||||
|
w.u16LE(0) // unk0x0A
|
||||||
|
w.u8(4) // nPathType
|
||||||
|
w.i16LE(75) // nVelocity = 75%
|
||||||
|
w.u8(0)
|
||||||
|
|
||||||
|
const event = decode0x67NpcMove(w.toUint8Array())
|
||||||
|
expect(event.type).toBe('UnitMove')
|
||||||
|
if (event.type === 'UnitMove') {
|
||||||
|
expect(event.id).toBe(101)
|
||||||
|
expect(event.mode).toBe('walk')
|
||||||
|
expect(event.to).toEqual({ x: 1234, y: 5678 })
|
||||||
|
expect(event.pathType).toBe(4)
|
||||||
|
expect(event.velocityPercent).toBe(75)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decodes nPathType and nVelocity from 0x68 NpcMoveToTarget', () => {
|
||||||
|
// 0x68 packet:
|
||||||
|
// [0] 0x68
|
||||||
|
// [1..4] unitId u32LE
|
||||||
|
// [5] moveType u8 (0x17 run)
|
||||||
|
// [6..7] currentX u16LE (100)
|
||||||
|
// [8..9] currentY u16LE (200)
|
||||||
|
// [10] targetType u8 (0 = player)
|
||||||
|
// [11..14] targetId u32LE (1)
|
||||||
|
// [15..16] unk0x0F u16LE
|
||||||
|
// [17] nPathType u8 (3)
|
||||||
|
// [18..19] nVelocity i16LE (120 = 120%)
|
||||||
|
// [20] unk u8
|
||||||
|
const w = new ByteWriter(21)
|
||||||
|
w.u8(0x68)
|
||||||
|
w.u32LE(202)
|
||||||
|
w.u8(0x17) // run
|
||||||
|
w.u16LE(100)
|
||||||
|
w.u16LE(200)
|
||||||
|
w.u8(0) // targetType = player
|
||||||
|
w.u32LE(1) // targetId
|
||||||
|
w.u16LE(0) // unk0x0F
|
||||||
|
w.u8(3) // nPathType
|
||||||
|
w.i16LE(120) // nVelocity
|
||||||
|
w.u8(0)
|
||||||
|
|
||||||
|
const event = decode0x68NpcMoveToTarget(w.toUint8Array())
|
||||||
|
expect(event.type).toBe('UnitMove')
|
||||||
|
if (event.type === 'UnitMove') {
|
||||||
|
expect(event.id).toBe(202)
|
||||||
|
expect(event.mode).toBe('run')
|
||||||
|
expect(event.pathType).toBe(3)
|
||||||
|
expect(event.velocityPercent).toBe(120)
|
||||||
|
expect(event.target).toEqual({ unitType: 0, id: 1 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveRemoteMoveSpeed with MonStats & velocityPercent (Issue #586)', () => {
|
||||||
|
it('resolves authentic monster speed using MonStats.txt data for NPCs and monsters', () => {
|
||||||
|
// Akara (classId 148): MonStats Velocity = 3 -> 1.171875 sub-tiles/sec
|
||||||
|
const akaraSpeed = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 1,
|
||||||
|
classId: 148,
|
||||||
|
running: false,
|
||||||
|
})
|
||||||
|
expect(akaraSpeed.speedSubTilesPerSec).toBe(1.171875)
|
||||||
|
|
||||||
|
// Zombie (classId 19): MonStats Velocity = 15 -> 5.859375 sub-tiles/sec
|
||||||
|
const zombieSpeed = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 1,
|
||||||
|
classId: 19,
|
||||||
|
running: false,
|
||||||
|
})
|
||||||
|
expect(zombieSpeed.speedSubTilesPerSec).toBe(5.859375)
|
||||||
|
|
||||||
|
// Fallen (classId 0): MonStats Velocity = 9 -> 3.515625 sub-tiles/sec
|
||||||
|
const fallenSpeed = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 1,
|
||||||
|
classId: 0,
|
||||||
|
running: false,
|
||||||
|
})
|
||||||
|
expect(fallenSpeed.speedSubTilesPerSec).toBe(3.515625)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales monster speed with nVelocity while maintaining 25% floor', () => {
|
||||||
|
// Zombie at 75% speed
|
||||||
|
const chilledZombie = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 1,
|
||||||
|
classId: 19,
|
||||||
|
running: false,
|
||||||
|
velocityPercent: 75,
|
||||||
|
})
|
||||||
|
expect(chilledZombie.speedSubTilesPerSec).toBe(5.859375)
|
||||||
|
|
||||||
|
// Severe slow (10% velocityPercent) clamped to 25% floor:
|
||||||
|
const frozenZombie = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 1,
|
||||||
|
classId: 19,
|
||||||
|
running: false,
|
||||||
|
velocityPercent: 10,
|
||||||
|
})
|
||||||
|
// 25% floor: computeMonsterMoveSpeedSubTilesPerSec(19, false, 25)
|
||||||
|
expect(frozenZombie.speedSubTilesPerSec).toBe(
|
||||||
|
computeMonsterMoveSpeedSubTilesPerSec(19, false, 25),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales remote player speed with nVelocity while maintaining 25% floor', () => {
|
||||||
|
// Remote player walking at 50% speed
|
||||||
|
const chilledPlayer = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 0,
|
||||||
|
running: false,
|
||||||
|
velocityPercent: 50,
|
||||||
|
})
|
||||||
|
expect(chilledPlayer.speedSubTilesPerSec).toBe(D2_BASE_WALK_SUBTILES_PER_SEC * 0.5)
|
||||||
|
|
||||||
|
// Remote player at 10% speed clamped to 25% floor
|
||||||
|
const frozenPlayer = resolveRemoteMoveSpeed({
|
||||||
|
unitType: 0,
|
||||||
|
running: false,
|
||||||
|
velocityPercent: 10,
|
||||||
|
})
|
||||||
|
expect(frozenPlayer.speedSubTilesPerSec).toBe(D2_BASE_WALK_SUBTILES_PER_SEC * 0.25)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RemoteEntityInterpolator Kinematics & Parity (Issue #598)', () => {
|
||||||
|
it('advances remote monsters at authentic velocity without 1.5x synthetic catchup acceleration', () => {
|
||||||
|
const interpolator = new RemoteEntityInterpolator()
|
||||||
|
|
||||||
|
// Spawn Akara at visual position (98, 100) lagging behind server
|
||||||
|
interpolator.spawnOrSnap({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 10,
|
||||||
|
classId: 148,
|
||||||
|
x: 98,
|
||||||
|
y: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server sends move to (105, 100) with server position at (100, 100)
|
||||||
|
// Server segment distance is 5, but visual distance is 7
|
||||||
|
const moveCmd = interpolator.startMove({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 10,
|
||||||
|
currentX: 100,
|
||||||
|
currentY: 100,
|
||||||
|
targetX: 105,
|
||||||
|
targetY: 100,
|
||||||
|
running: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In 1.13c parity (Issue #598): speed is strictly authentic MonStats speed (1.171875),
|
||||||
|
// NOT boosted by arbitrary 1.5x catchup multiplier
|
||||||
|
expect(moveCmd.speedSubTilesPerSec).toBe(1.171875)
|
||||||
|
|
||||||
|
// Step 1 second (1000ms)
|
||||||
|
interpolator.update(1000)
|
||||||
|
const state = interpolator.getEntityState(1, 10)!
|
||||||
|
expect(state.moving).toBe(true)
|
||||||
|
// Advance = 1.171875 sub-tiles from currentX (98)
|
||||||
|
expect(state.x).toBeCloseTo(98 + 1.171875, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settles cleanly on authoritative stop packets without post-stop sliding deceleration', () => {
|
||||||
|
const interpolator = new RemoteEntityInterpolator()
|
||||||
|
|
||||||
|
interpolator.spawnOrSnap({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 20,
|
||||||
|
classId: 19, // Zombie
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
interpolator.startMove({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 20,
|
||||||
|
targetX: 110,
|
||||||
|
targetY: 100,
|
||||||
|
running: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update 500ms -> unit moves toward target
|
||||||
|
interpolator.update(500)
|
||||||
|
const movingState = interpolator.getEntityState(1, 20)!
|
||||||
|
expect(movingState.moving).toBe(true)
|
||||||
|
|
||||||
|
// Server stop packet received at movingState.x
|
||||||
|
const stoppedState = interpolator.stopEntity({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: 20,
|
||||||
|
x: movingState.x,
|
||||||
|
y: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In 1.13c parity (Issue #598): unit immediately settles at authoritative stop position,
|
||||||
|
// moving is set to false, and no post-stop overshoot happens
|
||||||
|
expect(stoppedState.moving).toBe(false)
|
||||||
|
expect(stoppedState.x).toBe(movingState.x)
|
||||||
|
expect(stoppedState.y).toBe(100)
|
||||||
|
|
||||||
|
// Further updates do not move the unit
|
||||||
|
interpolator.update(500)
|
||||||
|
const afterUpdateState = interpolator.getEntityState(1, 20)!
|
||||||
|
expect(afterUpdateState.moving).toBe(false)
|
||||||
|
expect(afterUpdateState.x).toBe(movingState.x)
|
||||||
|
expect(afterUpdateState.y).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue