Fix remote entity velocity, player FRW kinematics, PATHTYPE_STRAIGHT, and gateModeBase (Closes #586, #587, #588, #598, #600)

This commit is contained in:
troytt 2026-10-02 23:16:40 +00:00
parent 3c7586b500
commit 17fd1f1b6c
8 changed files with 1191 additions and 69 deletions

View File

@ -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,13 +247,18 @@ 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 {
if (classId !== undefined) {
state.classId = classId
}
if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
const drift = Math.hypot(cmd.currentX - state.x, cmd.currentY - state.y) const drift = Math.hypot(cmd.currentX - state.x, cmd.currentY - state.y)
if (drift > this.snapThreshold) { if (drift > this.snapThreshold) {
state.x = cmd.currentX state.x = cmd.currentX
state.y = cmd.currentY state.y = cmd.currentY
} }
} }
}
state.targetX = cmd.targetX state.targetX = cmd.targetX
state.targetY = cmd.targetY state.targetY = cmd.targetY
@ -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

View File

@ -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,
} }

View File

@ -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') {
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 this.running ? 7 : 5
} }
return town ? 5 : 3
}
} }

View File

@ -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 } : {}),
} }
} }

View File

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

View File

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

View File

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

View File

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