diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 0c65ec5..b24daad 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -23,6 +23,7 @@ import { targetUnitTurnTowardPoint, type UnitTurnState, } from '../../common/units/direction.ts' +import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts' import { D2_BASE_RUN_SUBTILES_PER_SEC, D2_BASE_WALK_SUBTILES_PER_SEC, @@ -35,6 +36,7 @@ const DEFAULT_REMOTE_SNAP_THRESHOLD = 5.0 export interface RemoteEntityMotionState { readonly unitType: number readonly unitId: number + readonly classId?: number | undefined readonly x: number readonly y: number readonly targetX: number @@ -55,13 +57,17 @@ export type RemoteEntityState = RemoteEntityMotionState export interface RemoteMoveCommand { readonly unitType: number readonly unitId: number - readonly currentX?: number - readonly currentY?: number + readonly classId?: number | undefined + readonly currentX?: number | undefined + readonly currentY?: number | undefined readonly targetX: number readonly targetY: number - readonly running?: boolean - readonly moveType?: number - readonly speedSubTilesPerSec?: number + readonly running?: boolean | undefined + readonly moveType?: number | undefined + readonly speedSubTilesPerSec?: number | undefined + readonly velocityPercent?: number | undefined + readonly nVelocity?: number | undefined + readonly pathType?: number | undefined } export interface RemoteStopCommand { @@ -69,12 +75,13 @@ export interface RemoteStopCommand { readonly unitId: number readonly x: number readonly y: number - readonly hardSnapThreshold?: number + readonly hardSnapThreshold?: number | undefined } interface MutableRemoteEntityMotion { unitType: number unitId: number + classId?: number | undefined x: number y: number targetX: number @@ -93,6 +100,7 @@ function toState(state: MutableRemoteEntityMotion): RemoteEntityMotionState { return { unitType: state.unitType, unitId: state.unitId, + ...(state.classId !== undefined ? { classId: state.classId } : {}), x: state.x, y: state.y, targetX: state.targetX, @@ -107,15 +115,22 @@ function toState(state: MutableRemoteEntityMotion): RemoteEntityMotionState { } /** - * Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte into `{ running, speedSubTilesPerSec }`. - * In D2 1.13c: - * - `0x17` (23) or bit `0x10` / `0x02` indicates running - * - `0x01` indicates walking + * Convert D2 S2C `0x67` / `0x68` / `0x0F` `moveType` byte and unit stats into `{ running, speedSubTilesPerSec }`. + * + * 1.13c Ground Truth: + * - 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: { - readonly running?: boolean - readonly moveType?: number - readonly speedSubTilesPerSec?: number + readonly running?: boolean | undefined + readonly moveType?: number | undefined + 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 } { const running = options.running ?? @@ -127,9 +142,25 @@ export function resolveRemoteMoveSpeed(options: { if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { 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 { 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 x: number readonly y: number - readonly dir64?: number + readonly dir64?: number | undefined + readonly classId?: number | undefined }): RemoteEntityMotionState { if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64') const key = entityKey(params.unitType, params.unitId) @@ -162,14 +194,21 @@ export class RemoteEntityInterpolator { : previous ? { ...previous.turnState } : createUnitTurnState(0) + const classId = params.classId ?? previous?.classId + const { speedSubTilesPerSec } = resolveRemoteMoveSpeed({ + running: false, + unitType: params.unitType, + classId, + }) const state: MutableRemoteEntityMotion = { unitType: params.unitType, unitId: params.unitId >>> 0, + classId, x: params.x, y: params.y, targetX: params.x, targetY: params.y, - speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC, + speedSubTilesPerSec, moving: false, running: false, turnState, @@ -183,8 +222,12 @@ export class RemoteEntityInterpolator { */ startMove(cmd: RemoteMoveCommand): RemoteEntityMotionState { const key = entityKey(cmd.unitType, cmd.unitId) - const { running, speedSubTilesPerSec } = resolveRemoteMoveSpeed(cmd) 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 startY = cmd.currentY ?? state?.y ?? cmd.targetY @@ -193,6 +236,7 @@ export class RemoteEntityInterpolator { state = { unitType: cmd.unitType, unitId: cmd.unitId >>> 0, + classId, x: startX, y: startY, targetX: cmd.targetX, @@ -203,11 +247,16 @@ export class RemoteEntityInterpolator { turnState: createUnitTurnState(0), } this.entities.set(key, state) - } else 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 + } 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) + if (drift > this.snapThreshold) { + state.x = cmd.currentX + state.y = cmd.currentY + } } } @@ -222,14 +271,7 @@ export class RemoteEntityInterpolator { if (dist > 1e-4) { state.moving = true 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 - 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 - } - } + // 1.13c Parity (Issue #598): Units advance cleanly along waypoints at authentic velocity without synthetic 1.5x acceleration } else { state.x = state.targetX state.y = state.targetY diff --git a/src/client/motion/pathfind.ts b/src/client/motion/pathfind.ts index 8528566..e68f7d2 100644 --- a/src/client/motion/pathfind.ts +++ b/src/client/motion/pathfind.ts @@ -56,6 +56,13 @@ export interface PathfindResult { 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 OCTILE_DIAG_DIFF = SQRT2 - 1 @@ -164,6 +171,82 @@ export function hasLineOfSightWorld( 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`. * 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( grid: SubTileWalkabilityGrid, @@ -311,6 +402,12 @@ export function findPathLocal( goalY: number, options: PathfindOptions = {}, ): 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 smooth = options.smooth ?? true const nearestRadius = options.nearestTargetRadius ?? 10 @@ -353,18 +450,37 @@ export function findPathLocal( return { path: [pt], rawPath: [pt], reachedGoal: true, expandedNodes: 1 } } - // Fast path: direct line-of-sight between start and goal - if (smooth && hasLineOfSightLocal(grid, sx, sy, gx, gy)) { - const startPt = { x: sx, y: sy } - const goalPt = { x: gx, y: gy } + // PATHTYPE_STRAIGHT: trace straight ray + const ray = traceStraightRayLocal(grid, sx, sy, gx, gy) + if (!ray.blocked) { + const rawPath = ray.points.slice(0, MAXPATHLEN) + const finalPath = smooth ? [{ x: sx, y: sy }, { x: gx, y: gy }] : rawPath return { - path: [startPt, goalPt], - rawPath: [startPt, goalPt], + path: finalPath, + rawPath, reachedGoal: true, 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 gScore = new Float32Array(totalCells) gScore.fill(Infinity) @@ -436,8 +552,19 @@ export function findPathLocal( } const endIdx = reachedGoal ? goalIdx : allowPartial ? bestIdx : -1 - if (endIdx < 0) { - return { path: [], rawPath: [], reachedGoal: false, expandedNodes } + if (endIdx < 0 || (!reachedGoal && endIdx === startIdx)) { + // 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[] = [] @@ -451,10 +578,11 @@ export function findPathLocal( } rawPath.reverse() - const finalPath = smooth ? smoothPathLocal(grid, rawPath) : rawPath + const cappedRaw = rawPath.slice(0, MAXPATHLEN) + const finalPath = smooth ? smoothPathLocal(grid, cappedRaw) : cappedRaw return { - path: finalPath, - rawPath, + path: finalPath.slice(0, MAXPATHLEN), + rawPath: cappedRaw, reachedGoal, expandedNodes, } diff --git a/src/client/motion/predictor.ts b/src/client/motion/predictor.ts index fda57fe..9242246 100644 --- a/src/client/motion/predictor.ts +++ b/src/client/motion/predictor.ts @@ -58,6 +58,11 @@ export interface PredictorConfig { readonly walkSpeedSubTilesPerSec?: number readonly runSpeedSubTilesPerSec?: 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. */ @@ -87,6 +92,10 @@ export interface ServerPositionCheck { * player stand in TN instead of NU. */ readonly inTown: boolean + /** + * Optional authoritative player mode (PlrMode enum / integer from D2Client). + */ + readonly plrMode?: number | undefined } export type ServerPositionVerdictReason = @@ -153,6 +162,11 @@ export class LocalMovementPredictor { private walkSpeed: number private runSpeed: number private fasterRunWalkPercent: number + private skillVelocityPercent: number + private armorSpeedPenaltyPercent: number + private slowPercent: number + private staminaExhausted: boolean + private plrMode?: number | undefined private running = true private readonly turnState: UnitTurnState private waypoints: SubTilePoint[] = [] @@ -164,19 +178,41 @@ export class LocalMovementPredictor { this.walkSpeed = config.walkSpeedSubTilesPerSec ?? D2_BASE_WALK_SUBTILES_PER_SEC this.runSpeed = config.runSpeedSubTilesPerSec ?? D2_BASE_RUN_SUBTILES_PER_SEC 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) } /** * 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 { - 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 effFrw = frw > 0 ? Math.floor((150 * frw) / (150 + frw)) : frw - return Math.max(1.5, base + D2_BASE_WALK_SUBTILES_PER_SEC * (effFrw / 100)) + const effItemFrw = frw > 0 ? Math.floor((150 * frw) / (150 + frw)) : frw + + 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 { @@ -187,6 +223,74 @@ export class LocalMovementPredictor { 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 * `0x15 ReassignPlayer` behaviour: D2Client 0x6FB5F640 → 0x6FB57E20 repositions the unit @@ -323,13 +427,13 @@ export class LocalMovementPredictor { const segDist = Math.hypot(dx, dy) const segTimeMs = speed > 0 ? (segDist / speed) * 1000 : 0 - if (remainingTimeMs >= segTimeMs) { + if (remainingTimeMs >= segTimeMs - 1e-4) { if (segTimeMs > 0) { advanceUnitTurnByDtMs(this.turnState, segTimeMs) } this.logicalX = target.x this.logicalY = target.y - remainingTimeMs -= segTimeMs + remainingTimeMs = Math.max(0, remainingTimeMs - segTimeMs) this.waypointIndex++ this.faceCurrentWaypoint() } else { @@ -391,10 +495,13 @@ export class LocalMovementPredictor { 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 = source.kind === 'player-stop' ? GATE_FLAG1_THRESHOLD - : this.gateModeBase(check.inTown) + ((Math.trunc(check.rttMs) + 50) >>> 7) + : modeBase + ((Math.trunc(check.rttMs) + 50) >>> 7) const exceeded = Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold 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 - * walking, RN running — mapped to the 0x6FADA1B0 tolerance base: NU 3, RN 7, others 5. + * The local player's mode tolerance base for D2Client 0x6FADA1B0: + * - 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 { - if (this.waypointIndex >= this.waypoints.length) return inTown ? 5 : 3 - return this.running ? 7 : 5 + gateModeBase(modeOrInTown?: number | boolean, inTown = false): number { + 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 town ? 5 : 3 } } diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index 04dad3f..e61eeee 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -149,12 +149,12 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent { const moveType = r.u8() const targetX = r.u16LE() const targetY = r.u16LE() - let pathType = 0 - let velocityPercent = 0 + let pathType: number | undefined + let velocityPercent: number | undefined if (r.remaining >= 5) { - r.u16LE() // unk0x0A - pathType = r.u8() - velocityPercent = r.i16LE() + r.u16LE() // unk0x0A, unk0x0B + pathType = r.u8() // 0x0C nPathType + velocityPercent = r.i16LE() // 0x0D..0x0E nVelocity } return { type: 'UnitMove', @@ -162,8 +162,8 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent { id, mode: resolveMoveMode(moveType), to: { x: targetX, y: targetY }, - ...(pathType !== 0 ? { pathType } : {}), - ...(velocityPercent !== 0 ? { velocityPercent } : {}), + ...(pathType !== undefined ? { pathType } : {}), + ...(velocityPercent !== undefined ? { velocityPercent } : {}), } } @@ -177,12 +177,12 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent { const currentY = r.u16LE() const targetType = clampUnitType(r.u8()) const targetId = r.u32LE() - let pathType = 0 - let velocityPercent = 0 + let pathType: number | undefined + let velocityPercent: number | undefined if (r.remaining >= 5) { - r.u16LE() // unk0x0F - pathType = r.u8() - velocityPercent = r.i16LE() + r.u16LE() // unk0x0F, unk0x10 + pathType = r.u8() // 0x11 nPathType + velocityPercent = r.i16LE() // 0x12..0x13 nVelocity } return { type: 'UnitMove', @@ -192,8 +192,8 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent { from: { x: currentX, y: currentY }, to: { x: currentX, y: currentY }, target: { unitType: targetType, id: targetId }, - ...(pathType !== 0 ? { pathType } : {}), - ...(velocityPercent !== 0 ? { velocityPercent } : {}), + ...(pathType !== undefined ? { pathType } : {}), + ...(velocityPercent !== undefined ? { velocityPercent } : {}), } } diff --git a/tests/client/motion-kinematics-batch5.test.ts b/tests/client/motion-kinematics-batch5.test.ts new file mode 100644 index 0000000..c305528 --- /dev/null +++ b/tests/client/motion-kinematics-batch5.test.ts @@ -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 }) + }) + }) +}) diff --git a/tests/client/motion-predictor.test.ts b/tests/client/motion-predictor.test.ts new file mode 100644 index 0000000..ac8b3ff --- /dev/null +++ b/tests/client/motion-predictor.test.ts @@ -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) + }) +}) diff --git a/tests/client/pathfind.test.ts b/tests/client/pathfind.test.ts new file mode 100644 index 0000000..e759fe0 --- /dev/null +++ b/tests/client/pathfind.test.ts @@ -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) + }) +}) diff --git a/tests/client/remote-interpolation.test.ts b/tests/client/remote-interpolation.test.ts new file mode 100644 index 0000000..4a5019a --- /dev/null +++ b/tests/client/remote-interpolation.test.ts @@ -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) + }) +})