fix(client): keep unit animation facing aligned with movement direction
The walk/run clip often faced away from the direction of travel: - deltaToDir16 used 0 = +X with rounding, while 1.13c clips start at south-west; units further reduced it with dir16 % 8 and the renderer with % dirCount, so 8-direction monsters showed the wrong row. - re-pathing every frame while the mouse was held made the first path point the centre of the current cell, so the unit stepped back and turned around before moving on. - server position packets rubber-banded the local player towards a lagging position, and spawnOrSnap reset the facing to 0. Unit facing is now the 1.13c dir64 (D2Common 0x6FD5CF40), set once per path segment and kept while idle, mapped to the clip row with the D2CMP and moveToWorldSubTile paths from the rounded cell. The local player follows D2Client's position gate 0x6FADA1B0: out-of-tolerance positions send C2S 0x5F instead of moving the predictor, 0x15 is the authoritative snap, and replay follows the recorded positions.
This commit is contained in:
parent
ea252b0414
commit
dd775c8156
|
|
@ -8,16 +8,21 @@
|
||||||
* - `0x67 NPCMove` (`{ unitId, moveType, x, y, targetX, targetY, ... }`)
|
* - `0x67 NPCMove` (`{ unitId, moveType, x, y, targetX, targetY, ... }`)
|
||||||
* - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`)
|
* - `0x68 NPCMoveToTarget` (`{ unitId, moveType, x, y, targetUnitType, targetId, ... }`)
|
||||||
* - `0x6D NPCStop` (`{ unitId, x, y, life }`)
|
* - `0x6D NPCStop` (`{ unitId, x, y, life }`)
|
||||||
|
*
|
||||||
|
* Facings are dir64 values (`common/units/direction.ts`), set once when a segment starts
|
||||||
|
* (like D2Common 0x6FD5D240) and kept while the segment is walked and after it ends.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { assertDir64, dir64FromPoints } from '../../common/units/direction.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,
|
||||||
D2_TICK_MS,
|
D2_TICK_MS,
|
||||||
DEFAULT_RUBBER_BAND_THRESHOLD,
|
|
||||||
deltaToDir16,
|
|
||||||
} from './predictor.ts'
|
} from './predictor.ts'
|
||||||
|
|
||||||
|
/** Default drift (sub-tiles) beyond which a remote unit snaps to the server position. */
|
||||||
|
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
|
||||||
|
|
@ -28,7 +33,8 @@ export interface RemoteEntityMotionState {
|
||||||
readonly speedSubTilesPerSec: number
|
readonly speedSubTilesPerSec: number
|
||||||
readonly moving: boolean
|
readonly moving: boolean
|
||||||
readonly running: boolean
|
readonly running: boolean
|
||||||
readonly dir16: number
|
/** Facing in the engine's 64-direction space. */
|
||||||
|
readonly dir64: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteMoveCommand {
|
export interface RemoteMoveCommand {
|
||||||
|
|
@ -61,7 +67,7 @@ interface MutableRemoteEntityMotion {
|
||||||
speedSubTilesPerSec: number
|
speedSubTilesPerSec: number
|
||||||
moving: boolean
|
moving: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
dir16: number
|
dir64: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityKey(unitType: number, unitId: number): string {
|
function entityKey(unitType: number, unitId: number): string {
|
||||||
|
|
@ -98,20 +104,23 @@ export class RemoteEntityInterpolator {
|
||||||
private readonly snapThreshold: number
|
private readonly snapThreshold: number
|
||||||
|
|
||||||
constructor(options: { readonly snapThreshold?: number } = {}) {
|
constructor(options: { readonly snapThreshold?: number } = {}) {
|
||||||
this.snapThreshold = options.snapThreshold ?? DEFAULT_RUBBER_BAND_THRESHOLD
|
this.snapThreshold = options.snapThreshold ?? DEFAULT_REMOTE_SNAP_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register or hard-snap an entity at `(x, y)`.
|
* Register or hard-snap an entity at `(x, y)`. Without `dir64` a known entity keeps its
|
||||||
|
* facing (a `0x15` reassign carries no direction); a new one faces dir64 0.
|
||||||
*/
|
*/
|
||||||
spawnOrSnap(params: {
|
spawnOrSnap(params: {
|
||||||
readonly unitType: number
|
readonly unitType: number
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
readonly x: number
|
readonly x: number
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly dir16?: number
|
readonly dir64?: number
|
||||||
}): RemoteEntityMotionState {
|
}): RemoteEntityMotionState {
|
||||||
|
if (params.dir64 !== undefined) assertDir64(params.dir64, 'dir64')
|
||||||
const key = entityKey(params.unitType, params.unitId)
|
const key = entityKey(params.unitType, params.unitId)
|
||||||
|
const previous = this.entities.get(key)
|
||||||
const state: MutableRemoteEntityMotion = {
|
const state: MutableRemoteEntityMotion = {
|
||||||
unitType: params.unitType,
|
unitType: params.unitType,
|
||||||
unitId: params.unitId >>> 0,
|
unitId: params.unitId >>> 0,
|
||||||
|
|
@ -122,7 +131,7 @@ export class RemoteEntityInterpolator {
|
||||||
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
|
speedSubTilesPerSec: D2_BASE_WALK_SUBTILES_PER_SEC,
|
||||||
moving: false,
|
moving: false,
|
||||||
running: false,
|
running: false,
|
||||||
dir16: (params.dir16 ?? 0) & 15,
|
dir64: params.dir64 ?? previous?.dir64 ?? 0,
|
||||||
}
|
}
|
||||||
this.entities.set(key, state)
|
this.entities.set(key, state)
|
||||||
return { ...state }
|
return { ...state }
|
||||||
|
|
@ -150,7 +159,7 @@ export class RemoteEntityInterpolator {
|
||||||
speedSubTilesPerSec,
|
speedSubTilesPerSec,
|
||||||
moving: false,
|
moving: false,
|
||||||
running,
|
running,
|
||||||
dir16: 0,
|
dir64: 0,
|
||||||
}
|
}
|
||||||
this.entities.set(key, state)
|
this.entities.set(key, state)
|
||||||
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
} else if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
||||||
|
|
@ -171,7 +180,7 @@ export class RemoteEntityInterpolator {
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
if (dist > 1e-4) {
|
if (dist > 1e-4) {
|
||||||
state.moving = true
|
state.moving = true
|
||||||
state.dir16 = deltaToDir16(dx, dy, state.dir16)
|
state.dir64 = dir64FromPoints(state.x, state.y, state.targetX, state.targetY, state.dir64)
|
||||||
// If visual position lagged behind server start, slightly boost speed to catch up smoothly
|
// If visual position lagged behind server start, slightly boost speed to catch up smoothly
|
||||||
if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
if (cmd.currentX !== undefined && cmd.currentY !== undefined) {
|
||||||
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
|
const serverSegDist = Math.hypot(cmd.targetX - cmd.currentX, cmd.targetY - cmd.currentY)
|
||||||
|
|
@ -216,11 +225,12 @@ export class RemoteEntityInterpolator {
|
||||||
state.targetY = cmd.y
|
state.targetY = cmd.y
|
||||||
state.moving = false
|
state.moving = false
|
||||||
} else {
|
} else {
|
||||||
// Smoothly finish the small remaining glide to the authoritative stop point
|
// Smoothly finish the small remaining glide to the authoritative stop point, facing it so
|
||||||
|
// the unit never slides backwards.
|
||||||
state.targetX = cmd.x
|
state.targetX = cmd.x
|
||||||
state.targetY = cmd.y
|
state.targetY = cmd.y
|
||||||
state.moving = true
|
state.moving = true
|
||||||
state.dir16 = deltaToDir16(dx, dy, state.dir16)
|
state.dir64 = dir64FromPoints(state.x, state.y, cmd.x, cmd.y, state.dir64)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...state }
|
return { ...state }
|
||||||
|
|
@ -235,7 +245,7 @@ export class RemoteEntityInterpolator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance all interpolated entities by `dtMs` milliseconds.
|
* Advance all interpolated entities by `dtMs` milliseconds. Facings do not change here.
|
||||||
*/
|
*/
|
||||||
update(dtMs: number): void {
|
update(dtMs: number): void {
|
||||||
if (dtMs <= 0) return
|
if (dtMs <= 0) return
|
||||||
|
|
@ -255,7 +265,6 @@ export class RemoteEntityInterpolator {
|
||||||
const ratio = step / dist
|
const ratio = step / dist
|
||||||
state.x += dx * ratio
|
state.x += dx * ratio
|
||||||
state.y += dy * ratio
|
state.y += dy * ratio
|
||||||
state.dir16 = deltaToDir16(dx, dy, state.dir16)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
/**
|
/**
|
||||||
* Local Player Movement Predictor & Server Reconciliation
|
* Local Player Movement Predictor (`src/client/motion/predictor.ts`, Feature F12).
|
||||||
* (`src/client/motion/predictor.ts`, Feature F12).
|
|
||||||
*
|
*
|
||||||
* Steps the local player along an A* / string-pulled waypoint path at 25Hz (40ms per tick)
|
* Steps the local player along an A* / string-pulled waypoint path at 25Hz (40ms per tick)
|
||||||
* or arbitrary `dtMs` frame deltas, and reconciles predicted position against authoritative
|
* or arbitrary `dtMs` frame deltas, and applies the D2Client 1.13c rules for authoritative
|
||||||
* server packets:
|
* server positions:
|
||||||
* - `0x15 ReassignPlayer` (`{ unitType, unitId, x, y, flags }`)
|
* - `0x15 ReassignPlayer` repositions the player and drops its path
|
||||||
* - `0x95 PlayerLifeManaChange` (`{ x, y, ... }`)
|
* ({@link LocalMovementPredictor.setPosition}; D2Client 0x6FB5F640 → 0x6FB57E20, no
|
||||||
* - `0x96 WalkVerify` (`{ stamina, x, y, dX, dY }`)
|
* tolerance check).
|
||||||
|
* - `0x18` / `0x95` / `0x96` (`LifeMana` with a position) and `0x0D` go through the
|
||||||
|
* position tolerance gate D2Client 0x6FADA1B0
|
||||||
|
* ({@link LocalMovementPredictor.verifyServerPosition}). The client never moves the local
|
||||||
|
* player toward such a position; when the drift is out of tolerance it asks the server to
|
||||||
|
* resynchronise with C2S `0x5F`, and the server answers with `0x15` if it disagrees.
|
||||||
*
|
*
|
||||||
* Reconciliation policy:
|
* The facing is a dir64 (`common/units/direction.ts`), set only when a segment starts — on
|
||||||
* - When Euclidean drift `<= rubberBandThreshold` (default: `5` sub-tiles) and
|
* {@link LocalMovementPredictor.setPath} and on each waypoint advance — like the engine's
|
||||||
* `teleport !== true`, applies a smooth exponential rubber-band correction offset
|
* D2Common 0x6FD5D240, never per frame.
|
||||||
* that decays to 0 over `rubberBandHalfLifeMs`.
|
|
||||||
* - When drift `> rubberBandThreshold` (e.g. `> 5` sub-tiles) or `teleport === true`,
|
|
||||||
* hard-snaps immediately to the server position and clears active path prediction.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
|
||||||
import {
|
import {
|
||||||
findPathWorld,
|
findPathWorld,
|
||||||
type PathfindOptions,
|
type PathfindOptions,
|
||||||
|
|
@ -27,7 +29,19 @@ import {
|
||||||
export const D2_TICK_MS = 40 // 25Hz D2 game tick
|
export const D2_TICK_MS = 40 // 25Hz D2 game tick
|
||||||
export const D2_BASE_WALK_SUBTILES_PER_SEC = 9.0 // 6 yards/s * 1.5 sub-tiles/yard
|
export const D2_BASE_WALK_SUBTILES_PER_SEC = 9.0 // 6 yards/s * 1.5 sub-tiles/yard
|
||||||
export const D2_BASE_RUN_SUBTILES_PER_SEC = 13.5 // 9 yards/s * 1.5 sub-tiles/yard
|
export const D2_BASE_RUN_SUBTILES_PER_SEC = 13.5 // 9 yards/s * 1.5 sub-tiles/yard
|
||||||
export const DEFAULT_RUBBER_BAND_THRESHOLD = 5.0 // sub-tiles
|
|
||||||
|
/**
|
||||||
|
* D2Client 0x6FADA1B0 tolerance when the caller passes flag 1 (the `0x0D` mode handler
|
||||||
|
* D2Client 0x6FAC9830, e.g. its get-hit case at 0x6FAC99FB).
|
||||||
|
*/
|
||||||
|
const GATE_FLAG1_THRESHOLD = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D2Client 0x6FADA1B0 skips the "client is ahead of the server" exemption once the client is
|
||||||
|
* this far from the server position, as a squared distance (D2Common #10769, 0x6FD84580,
|
||||||
|
* returns `dx² + dy²` without a square root): 100 = 10 sub-tiles.
|
||||||
|
*/
|
||||||
|
const GATE_LEAD_MAX_DIST_SQ = 100
|
||||||
|
|
||||||
export interface PredictorConfig {
|
export interface PredictorConfig {
|
||||||
readonly initialX?: number
|
readonly initialX?: number
|
||||||
|
|
@ -35,71 +49,99 @@ export interface PredictorConfig {
|
||||||
readonly walkSpeedSubTilesPerSec?: number
|
readonly walkSpeedSubTilesPerSec?: number
|
||||||
readonly runSpeedSubTilesPerSec?: number
|
readonly runSpeedSubTilesPerSec?: number
|
||||||
readonly fasterRunWalkPercent?: number
|
readonly fasterRunWalkPercent?: number
|
||||||
readonly rubberBandThreshold?: number
|
|
||||||
readonly rubberBandCorrectionRate?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerPositionUpdate {
|
/** Which D2Client 0x6FADA1B0 caller is checking an authoritative position. */
|
||||||
|
export type ServerPositionSource =
|
||||||
|
/**
|
||||||
|
* `0x18` / `0x95` / `0x96` (callers 0x6FB5ED53 / 0x6FB5EC73 / 0x6FB5D9B8): gate flag 0 and
|
||||||
|
* target = position + (vx, vy), the server unit's motion vector.
|
||||||
|
*/
|
||||||
|
| { readonly kind: 'life-mana'; readonly vx: number; readonly vy: number }
|
||||||
|
/**
|
||||||
|
* `0x0D` for the local player: gate flag 1. D2Game sends the owner `0x0D` only for the
|
||||||
|
* death, get-hit, block and dead modes (and message mode 0x13); its stand / walk / run
|
||||||
|
* senders skip the owning client (D2MOO `PlrMsg.cpp`, `stru_6FD30708`).
|
||||||
|
*/
|
||||||
|
| { readonly kind: 'player-stop' }
|
||||||
|
|
||||||
|
export interface ServerPositionCheck {
|
||||||
|
/** Server sub-tile X (u16). */
|
||||||
readonly x: number
|
readonly x: number
|
||||||
|
/** Server sub-tile Y (u16). */
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly teleport?: boolean
|
readonly source: ServerPositionSource
|
||||||
readonly clearPathOnHardSnap?: boolean
|
/** Latest `0x8F` pong round trip in ms (D2Client `[0x6FBC9804]`). */
|
||||||
|
readonly rttMs: number
|
||||||
|
/**
|
||||||
|
* Whether the player's room is in town (D2Client `DUNGEON_IsRoomInTown`), which makes the
|
||||||
|
* player stand in TN instead of NU.
|
||||||
|
*/
|
||||||
|
readonly inTown: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReconcileResult {
|
export type ServerPositionVerdictReason =
|
||||||
readonly driftDistance: number
|
/** X or Y is 0: the gate returns before comparing anything. */
|
||||||
readonly mode: 'none' | 'rubber-band' | 'hard-snap'
|
| 'no-position'
|
||||||
readonly x: number
|
/** Both axes within the tolerance. */
|
||||||
readonly y: number
|
| 'within-threshold'
|
||||||
|
/** Out of tolerance, but the client is ahead along the server unit's motion. */
|
||||||
|
| 'leading-server'
|
||||||
|
/** Out of tolerance: the client asks for a resync. */
|
||||||
|
| 'exceeded'
|
||||||
|
|
||||||
|
export interface ServerPositionVerdict {
|
||||||
|
/** `request-resync`: send C2S `0x5F` with (`clientX`, `clientY`). The position is untouched. */
|
||||||
|
readonly action: 'none' | 'request-resync'
|
||||||
|
readonly reason: ServerPositionVerdictReason
|
||||||
|
/** The player's integer sub-tile X the gate compared (and reports in `0x5F`). */
|
||||||
|
readonly clientX: number
|
||||||
|
/** The player's integer sub-tile Y the gate compared (and reports in `0x5F`). */
|
||||||
|
readonly clientY: number
|
||||||
|
/** Per-axis tolerance in sub-tiles; 0 when the gate returned before computing it. */
|
||||||
|
readonly threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PredictorSnapshot {
|
export interface PredictorSnapshot {
|
||||||
/** Authoritative + predicted logical sub-tile X. */
|
/** Predicted logical sub-tile X. */
|
||||||
readonly logicalX: number
|
readonly logicalX: number
|
||||||
/** Authoritative + predicted logical sub-tile Y. */
|
/** Predicted logical sub-tile Y. */
|
||||||
readonly logicalY: number
|
readonly logicalY: number
|
||||||
/** Visual sub-tile X (includes decaying rubber-band offset). */
|
/** Drawn sub-tile X; always `logicalX` (1.13c draws the simulated position). */
|
||||||
readonly x: number
|
readonly x: number
|
||||||
/** Visual sub-tile Y (includes decaying rubber-band offset). */
|
/** Drawn sub-tile Y; always `logicalY`. */
|
||||||
readonly y: number
|
readonly y: number
|
||||||
/** Remaining rubber-band offset X. */
|
|
||||||
readonly errorOffsetX: number
|
|
||||||
/** Remaining rubber-band offset Y. */
|
|
||||||
readonly errorOffsetY: number
|
|
||||||
/** True if currently moving along a waypoint path. */
|
/** True if currently moving along a waypoint path. */
|
||||||
readonly moving: boolean
|
readonly moving: boolean
|
||||||
/** True if running (false if walking). */
|
/** True if running (false if walking). */
|
||||||
readonly running: boolean
|
readonly running: boolean
|
||||||
/** 16-direction isometric facing index (0..15). */
|
/** Facing in the engine's 64-direction space (`common/units/direction.ts`). */
|
||||||
readonly dir16: number
|
readonly dir64: number
|
||||||
/** Current active target waypoint, or null if idle. */
|
/** Current active target waypoint, or null if idle. */
|
||||||
readonly currentWaypoint: SubTilePoint | null
|
readonly currentWaypoint: SubTilePoint | null
|
||||||
/** Remaining waypoints including current target. */
|
/** Remaining waypoints including current target. */
|
||||||
readonly remainingWaypoints: readonly SubTilePoint[]
|
readonly remainingWaypoints: readonly SubTilePoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function assertSubTile(value: number, what: string): void {
|
||||||
* Convert a sub-tile delta `(dx, dy)` into a 16-direction D2 facing index (`0..15`).
|
if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
|
||||||
*/
|
throw new RangeError(`${what} must be a u16 sub-tile coordinate, got ${String(value)}`)
|
||||||
export function deltaToDir16(dx: number, dy: number, fallback = 0): number {
|
}
|
||||||
if (Math.abs(dx) < 1e-5 && Math.abs(dy) < 1e-5) return fallback & 15
|
}
|
||||||
const angle = Math.atan2(dy, dx) // -pi .. pi
|
|
||||||
const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2)
|
/** D2Common #10769 (0x6FD84580): squared distance, no square root. */
|
||||||
return Math.round(normalized * 16) & 15
|
function distanceSquared(x1: number, y1: number, x2: number, y2: number): number {
|
||||||
|
return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LocalMovementPredictor {
|
export class LocalMovementPredictor {
|
||||||
private logicalX: number
|
private logicalX: number
|
||||||
private logicalY: number
|
private logicalY: number
|
||||||
private errorOffsetX = 0
|
|
||||||
private errorOffsetY = 0
|
|
||||||
private walkSpeed: number
|
private walkSpeed: number
|
||||||
private runSpeed: number
|
private runSpeed: number
|
||||||
private fasterRunWalkPercent: number
|
private fasterRunWalkPercent: number
|
||||||
private readonly rubberBandThreshold: number
|
|
||||||
private readonly rubberBandCorrectionRate: number
|
|
||||||
private running = true
|
private running = true
|
||||||
private dir16 = 0
|
private dir64 = 0
|
||||||
private waypoints: SubTilePoint[] = []
|
private waypoints: SubTilePoint[] = []
|
||||||
private waypointIndex = 0
|
private waypointIndex = 0
|
||||||
|
|
||||||
|
|
@ -109,8 +151,6 @@ 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.rubberBandThreshold = config.rubberBandThreshold ?? DEFAULT_RUBBER_BAND_THRESHOLD
|
|
||||||
this.rubberBandCorrectionRate = config.rubberBandCorrectionRate ?? 12.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -133,41 +173,59 @@ export class LocalMovementPredictor {
|
||||||
this.fasterRunWalkPercent = frwPercent
|
this.fasterRunWalkPercent = frwPercent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* directly, whatever the packet's flag byte.
|
||||||
|
*/
|
||||||
setPosition(x: number, y: number): void {
|
setPosition(x: number, y: number): void {
|
||||||
this.logicalX = x
|
this.logicalX = x
|
||||||
this.logicalY = y
|
this.logicalY = y
|
||||||
this.errorOffsetX = 0
|
|
||||||
this.errorOffsetY = 0
|
|
||||||
this.waypoints = []
|
this.waypoints = []
|
||||||
this.waypointIndex = 0
|
this.waypointIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign a pre-computed waypoint list (first element may be the current position or
|
* Set the facing (dir64), e.g. when the server assigns one.
|
||||||
* the first target waypoint).
|
*/
|
||||||
|
setDirection(dir64: number): void {
|
||||||
|
assertDir64(dir64, 'dir64')
|
||||||
|
this.dir64 = dir64
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow `path` (world sub-tiles), as returned by `findPathWorld` from the player's cell.
|
||||||
|
*
|
||||||
|
* 1.13c path points never include the unit's own position (D2MOO `PathMisc.cpp`:
|
||||||
|
* `PathPoints[0]` is the next point, the start lives in `tStartCoord`), while our pathfinder
|
||||||
|
* returns the start cell first. Leading points in the player's current cell are therefore
|
||||||
|
* dropped; otherwise every re-path while moving would first step back to the cell centre.
|
||||||
|
* If nothing is left the player stops and keeps its facing.
|
||||||
*/
|
*/
|
||||||
setPath(path: readonly SubTilePoint[], running = this.running): void {
|
setPath(path: readonly SubTilePoint[], running = this.running): void {
|
||||||
this.running = running
|
this.running = running
|
||||||
if (path.length === 0) {
|
const cellX = Math.round(this.logicalX)
|
||||||
this.waypoints = []
|
const cellY = Math.round(this.logicalY)
|
||||||
this.waypointIndex = 0
|
let start = 0
|
||||||
return
|
while (
|
||||||
|
start < path.length &&
|
||||||
|
Math.round(path[start]!.x) === cellX &&
|
||||||
|
Math.round(path[start]!.y) === cellY
|
||||||
|
) {
|
||||||
|
start++
|
||||||
}
|
}
|
||||||
// Skip leading waypoint if it coincides with current logical position
|
this.waypoints = path.slice(start).map(p => ({ x: p.x, y: p.y }))
|
||||||
const first = path[0]!
|
|
||||||
const distFirst = Math.hypot(first.x - this.logicalX, first.y - this.logicalY)
|
|
||||||
const startIdx = distFirst < 0.05 && path.length > 1 ? 1 : 0
|
|
||||||
this.waypoints = path.slice(startIdx).map(p => ({ x: p.x, y: p.y }))
|
|
||||||
this.waypointIndex = 0
|
this.waypointIndex = 0
|
||||||
if (this.waypoints.length > 0) {
|
this.faceCurrentWaypoint()
|
||||||
const target = this.waypoints[0]!
|
|
||||||
this.dir16 = deltaToDir16(target.x - this.logicalX, target.y - this.logicalY, this.dir16)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute an A* + LOS string-pulled path on `grid` (in world sub-tiles) and start
|
* Compute an A* + LOS string-pulled path on `grid` (in world sub-tiles) and start
|
||||||
* moving along it.
|
* moving along it.
|
||||||
|
*
|
||||||
|
* The path starts from the player's cell, `Math.round` of the logical position, as in
|
||||||
|
* `CommandMapper`: `findPathWorld` floors its start, so a raw fractional position of N.5 or
|
||||||
|
* more would start the path one cell behind the player, which {@link setPath} cannot drop.
|
||||||
*/
|
*/
|
||||||
moveToWorldSubTile(
|
moveToWorldSubTile(
|
||||||
grid: SubTileWalkabilityGrid,
|
grid: SubTileWalkabilityGrid,
|
||||||
|
|
@ -178,7 +236,14 @@ export class LocalMovementPredictor {
|
||||||
if (options.running !== undefined) {
|
if (options.running !== undefined) {
|
||||||
this.running = options.running
|
this.running = options.running
|
||||||
}
|
}
|
||||||
const result = findPathWorld(grid, this.logicalX, this.logicalY, worldGoalX, worldGoalY, options)
|
const result = findPathWorld(
|
||||||
|
grid,
|
||||||
|
Math.round(this.logicalX),
|
||||||
|
Math.round(this.logicalY),
|
||||||
|
worldGoalX,
|
||||||
|
worldGoalY,
|
||||||
|
options,
|
||||||
|
)
|
||||||
this.setPath(result.path, this.running)
|
this.setPath(result.path, this.running)
|
||||||
return this.getRemainingWaypoints()
|
return this.getRemainingWaypoints()
|
||||||
}
|
}
|
||||||
|
|
@ -203,41 +268,24 @@ export class LocalMovementPredictor {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance predicted movement and rubber-band decay by `dtMs` milliseconds.
|
* Advance predicted movement by `dtMs` milliseconds.
|
||||||
*/
|
*/
|
||||||
update(dtMs: number): PredictorSnapshot {
|
update(dtMs: number): PredictorSnapshot {
|
||||||
if (dtMs <= 0) return this.getSnapshot()
|
if (dtMs <= 0) return this.getSnapshot()
|
||||||
const dtSec = dtMs / 1000
|
|
||||||
|
|
||||||
// Decay rubber-band error offset smoothly toward 0
|
let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * (dtMs / 1000)
|
||||||
if (Math.abs(this.errorOffsetX) > 1e-4 || Math.abs(this.errorOffsetY) > 1e-4) {
|
|
||||||
const decay = Math.exp(-this.rubberBandCorrectionRate * dtSec)
|
|
||||||
this.errorOffsetX *= decay
|
|
||||||
this.errorOffsetY *= decay
|
|
||||||
if (Math.abs(this.errorOffsetX) < 1e-3) this.errorOffsetX = 0
|
|
||||||
if (Math.abs(this.errorOffsetY) < 1e-3) this.errorOffsetY = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let remainingDist = this.getEffectiveSpeedSubTilesPerSec() * dtSec
|
|
||||||
while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) {
|
while (remainingDist > 0 && this.waypointIndex < this.waypoints.length) {
|
||||||
const target = this.waypoints[this.waypointIndex]!
|
const target = this.waypoints[this.waypointIndex]!
|
||||||
const dx = target.x - this.logicalX
|
const dx = target.x - this.logicalX
|
||||||
const dy = target.y - this.logicalY
|
const dy = target.y - this.logicalY
|
||||||
const segDist = Math.hypot(dx, dy)
|
const segDist = Math.hypot(dx, dy)
|
||||||
|
|
||||||
if (segDist <= 1e-5) {
|
|
||||||
this.logicalX = target.x
|
|
||||||
this.logicalY = target.y
|
|
||||||
this.waypointIndex++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dir16 = deltaToDir16(dx, dy, this.dir16)
|
|
||||||
if (remainingDist >= segDist) {
|
if (remainingDist >= segDist) {
|
||||||
this.logicalX = target.x
|
this.logicalX = target.x
|
||||||
this.logicalY = target.y
|
this.logicalY = target.y
|
||||||
remainingDist -= segDist
|
remainingDist -= segDist
|
||||||
this.waypointIndex++
|
this.waypointIndex++
|
||||||
|
this.faceCurrentWaypoint()
|
||||||
} else {
|
} else {
|
||||||
const ratio = remainingDist / segDist
|
const ratio = remainingDist / segDist
|
||||||
this.logicalX += dx * ratio
|
this.logicalX += dx * ratio
|
||||||
|
|
@ -255,62 +303,68 @@ export class LocalMovementPredictor {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile predicted player state against an authoritative server packet
|
* D2Client 1.13c position tolerance gate 0x6FADA1B0, for the local player.
|
||||||
* (`0x15 ReassignPlayer`, `0x95 PlayerLifeManaChange`, `0x96 WalkVerify`).
|
|
||||||
*
|
*
|
||||||
* - If `update.teleport === true` or `drift > rubberBandThreshold` (`5` sub-tiles),
|
* 1. X or Y of 0 → no check.
|
||||||
* performs a hard snap to `(update.x, update.y)`.
|
* 2. Client position = the player's integer sub-tile (D2Client 0x6FAB1630 / 0x6FAB1660;
|
||||||
* - If `0 < drift <= rubberBandThreshold`, shifts `logicalX/Y` to `(update.x, update.y)`
|
* logical N.0 is the centre of sub-tile N, so `Math.round`).
|
||||||
* while preserving visual continuity via `errorOffsetX/Y`, which smoothly decays to 0.
|
* 3. Tolerance: 10 for flag 1 (`player-stop`); otherwise the player's mode base — NU 3,
|
||||||
|
* RN 7, anything else (WL, TN, TW, …) 5 — plus `(rttMs + 50) >> 7`.
|
||||||
|
* 4. Within tolerance on both axes → no correction.
|
||||||
|
* 5. Out of tolerance with flag 0 and target X > 0, where target = position + (vx, vy):
|
||||||
|
* if `d²(client, server) < 100` and `d²(client, target) < d²(client, server)` the client
|
||||||
|
* is ahead along the server's motion → no correction.
|
||||||
|
* 6. Otherwise the local player sends C2S `0x5F` with its own position and does not move.
|
||||||
|
*
|
||||||
|
* Both "no correction" outcomes are the gate's soft branch, which only corrects units that are
|
||||||
|
* off screen at both positions (sprite test 0x6FB1E860); the local player is the camera target
|
||||||
|
* and always on screen. The hard branch also skips units without a room when the server
|
||||||
|
* position has none either (0x6FAB1400 / 0x6FABC2E0); callers handle "no room" before calling.
|
||||||
|
* The gate's early exit for dead units is not modelled: D2Game ignores `0x5F` from a dead
|
||||||
|
* player (D2MOO `PlrMsg.cpp`, 0x6FC88530).
|
||||||
*/
|
*/
|
||||||
reconcileServerPosition(update: ServerPositionUpdate, teleport?: boolean): ReconcileResult {
|
verifyServerPosition(check: ServerPositionCheck): ServerPositionVerdict {
|
||||||
const visualX = this.logicalX + this.errorOffsetX
|
assertSubTile(check.x, 'server x')
|
||||||
const visualY = this.logicalY + this.errorOffsetY
|
assertSubTile(check.y, 'server y')
|
||||||
const dx = update.x - this.logicalX
|
if (!Number.isFinite(check.rttMs) || check.rttMs < 0) {
|
||||||
const dy = update.y - this.logicalY
|
throw new RangeError(`rttMs must be a finite non-negative number, got ${String(check.rttMs)}`)
|
||||||
const driftDistance = Math.hypot(dx, dy)
|
}
|
||||||
const isTeleport = teleport ?? update.teleport === true
|
const { source } = check
|
||||||
|
if (source.kind === 'life-mana' && (!Number.isInteger(source.vx) || !Number.isInteger(source.vy))) {
|
||||||
|
throw new RangeError(`vx/vy must be integers, got ${String(source.vx)}/${String(source.vy)}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (isTeleport || driftDistance > this.rubberBandThreshold) {
|
const clientX = Math.round(this.logicalX)
|
||||||
this.logicalX = update.x
|
const clientY = Math.round(this.logicalY)
|
||||||
this.logicalY = update.y
|
if (check.x === 0 || check.y === 0) {
|
||||||
this.errorOffsetX = 0
|
return { action: 'none', reason: 'no-position', clientX, clientY, threshold: 0 }
|
||||||
this.errorOffsetY = 0
|
}
|
||||||
if (update.clearPathOnHardSnap !== false) {
|
|
||||||
this.waypoints = []
|
const threshold =
|
||||||
this.waypointIndex = 0
|
source.kind === 'player-stop'
|
||||||
}
|
? GATE_FLAG1_THRESHOLD
|
||||||
return {
|
: this.gateModeBase(check.inTown) + ((Math.trunc(check.rttMs) + 50) >>> 7)
|
||||||
driftDistance,
|
const exceeded =
|
||||||
mode: 'hard-snap',
|
Math.abs(check.x - clientX) > threshold || Math.abs(check.y - clientY) > threshold
|
||||||
x: this.logicalX,
|
if (!exceeded) {
|
||||||
y: this.logicalY,
|
return { action: 'none', reason: 'within-threshold', clientX, clientY, threshold }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.kind === 'life-mana') {
|
||||||
|
const targetX = (check.x + source.vx) & 0xffff
|
||||||
|
const targetY = (check.y + source.vy) & 0xffff
|
||||||
|
if (targetX > 0) {
|
||||||
|
const toServer = distanceSquared(clientX, clientY, check.x, check.y)
|
||||||
|
if (
|
||||||
|
toServer < GATE_LEAD_MAX_DIST_SQ &&
|
||||||
|
distanceSquared(clientX, clientY, targetX, targetY) < toServer
|
||||||
|
) {
|
||||||
|
return { action: 'none', reason: 'leading-server', clientX, clientY, threshold }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (driftDistance <= 1e-4) {
|
return { action: 'request-resync', reason: 'exceeded', clientX, clientY, threshold }
|
||||||
return {
|
|
||||||
driftDistance: 0,
|
|
||||||
mode: 'none',
|
|
||||||
x: visualX,
|
|
||||||
y: visualY,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smooth rubber-band: anchor logical position to authoritative server position,
|
|
||||||
// and store the visual delta in errorOffsetX/Y so the rendered position glides
|
|
||||||
// smoothly without a jarring pop.
|
|
||||||
this.logicalX = update.x
|
|
||||||
this.logicalY = update.y
|
|
||||||
this.errorOffsetX = visualX - update.x
|
|
||||||
this.errorOffsetY = visualY - update.y
|
|
||||||
|
|
||||||
return {
|
|
||||||
driftDistance,
|
|
||||||
mode: 'rubber-band',
|
|
||||||
x: this.logicalX + this.errorOffsetX,
|
|
||||||
y: this.logicalY + this.errorOffsetY,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(): PredictorSnapshot {
|
getSnapshot(): PredictorSnapshot {
|
||||||
|
|
@ -319,15 +373,32 @@ export class LocalMovementPredictor {
|
||||||
return {
|
return {
|
||||||
logicalX: this.logicalX,
|
logicalX: this.logicalX,
|
||||||
logicalY: this.logicalY,
|
logicalY: this.logicalY,
|
||||||
x: this.logicalX + this.errorOffsetX,
|
x: this.logicalX,
|
||||||
y: this.logicalY + this.errorOffsetY,
|
y: this.logicalY,
|
||||||
errorOffsetX: this.errorOffsetX,
|
|
||||||
errorOffsetY: this.errorOffsetY,
|
|
||||||
moving: currentWaypoint !== null,
|
moving: currentWaypoint !== null,
|
||||||
running: this.running,
|
running: this.running,
|
||||||
dir16: this.dir16,
|
dir64: this.dir64,
|
||||||
currentWaypoint,
|
currentWaypoint,
|
||||||
remainingWaypoints: this.waypoints.slice(this.waypointIndex),
|
remainingWaypoints: this.waypoints.slice(this.waypointIndex),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Face the current waypoint. The engine sets the facing whenever a unit starts a segment
|
||||||
|
* toward its next path point (D2Common 0x6FD5D240), not while walking it.
|
||||||
|
*/
|
||||||
|
private faceCurrentWaypoint(): void {
|
||||||
|
const target = this.waypoints[this.waypointIndex]
|
||||||
|
if (target === undefined) return
|
||||||
|
this.dir64 = dir64FromPoints(this.logicalX, this.logicalY, target.x, target.y, this.dir64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private gateModeBase(inTown: boolean): number {
|
||||||
|
if (this.waypointIndex >= this.waypoints.length) return inTown ? 5 : 3
|
||||||
|
return this.running ? 7 : 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { facingToDirection } from '../../common/units/character.ts'
|
import { dir64ToClipDirection } from '../../common/units/direction.ts'
|
||||||
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
||||||
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
||||||
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||||||
|
|
@ -346,7 +346,6 @@ export interface SceneSourceRenderContext {
|
||||||
readonly hudManager?: HudManager | null | undefined
|
readonly hudManager?: HudManager | null | undefined
|
||||||
readonly nowMs?: number | undefined
|
readonly nowMs?: number | undefined
|
||||||
readonly playerMoving?: boolean | undefined
|
readonly playerMoving?: boolean | undefined
|
||||||
readonly playerFacingRadians?: number | undefined
|
|
||||||
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
|
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,10 +419,7 @@ function drawSceneUnitDrawable(
|
||||||
charArt.clips?.[requestedClipKey] ??
|
charArt.clips?.[requestedClipKey] ??
|
||||||
(isMoving ? charArt.clips?.wl : charArt.clips?.nu)
|
(isMoving ? charArt.clips?.wl : charArt.clips?.nu)
|
||||||
const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
|
const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
|
||||||
const direction =
|
const direction = dir64ToClipDirection(unit.direction, dirCount)
|
||||||
ctx.playerFacingRadians !== undefined
|
|
||||||
? facingToDirection(ctx.playerFacingRadians, dirCount)
|
|
||||||
: ((unit.direction % dirCount) + dirCount) % dirCount
|
|
||||||
const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand
|
const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand
|
||||||
const groupIndex = baseGroup + direction
|
const groupIndex = baseGroup + direction
|
||||||
const group =
|
const group =
|
||||||
|
|
@ -490,7 +486,7 @@ function drawSceneUnitDrawable(
|
||||||
(isMoving ? art.clips?.wl : undefined) ??
|
(isMoving ? art.clips?.wl : undefined) ??
|
||||||
art.clips?.nu
|
art.clips?.nu
|
||||||
const dirCount = Math.max(1, clip?.directions ?? art.directions)
|
const dirCount = Math.max(1, clip?.directions ?? art.directions)
|
||||||
const direction = ((unit.direction % dirCount) + dirCount) % dirCount
|
const direction = dir64ToClipDirection(unit.direction, dirCount)
|
||||||
const baseGroup =
|
const baseGroup =
|
||||||
clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset
|
clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset
|
||||||
const groupIndex = baseGroup + direction
|
const groupIndex = baseGroup + direction
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-in
|
||||||
import type { LevelView } from '../map/level-view.ts'
|
import type { LevelView } from '../map/level-view.ts'
|
||||||
import type { MapService } from '../map/map-service.ts'
|
import type { MapService } from '../map/map-service.ts'
|
||||||
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
|
||||||
import { LocalMovementPredictor } from '../motion/predictor.ts'
|
import { LocalMovementPredictor, type ServerPositionSource } from '../motion/predictor.ts'
|
||||||
import {
|
import {
|
||||||
getGlobalSettingsStore,
|
getGlobalSettingsStore,
|
||||||
type SettingsStore,
|
type SettingsStore,
|
||||||
|
|
@ -71,6 +71,7 @@ import { getViewportProfile } from '../view/viewport-profile.ts'
|
||||||
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
|
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
|
||||||
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
|
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
|
||||||
import { OutboundRateLimiter } from './rate-limiter.ts'
|
import { OutboundRateLimiter } from './rate-limiter.ts'
|
||||||
|
import { isTownLevel } from '../../common/world/scene-population.ts'
|
||||||
|
|
||||||
export type OnlineSessionPhase =
|
export type OnlineSessionPhase =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
|
|
@ -883,6 +884,59 @@ export class OnlineSession {
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Local Player Server Position (D2Client 1.13c) ───────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check an authoritative local-player position with the 1.13c tolerance gate (D2Client
|
||||||
|
* 0x6FADA1B0, `LocalMovementPredictor.verifyServerPosition`). The player is never moved toward
|
||||||
|
* it: out of tolerance the client sends C2S `0x5F` with its own sub-tile, and the server
|
||||||
|
* answers with `0x15` if it disagrees.
|
||||||
|
*
|
||||||
|
* `.d2cap` replay is not part of the game: it feeds no local input to the predictor and has no
|
||||||
|
* server to answer `0x5F`, so there the local player takes the captured position directly.
|
||||||
|
*/
|
||||||
|
private verifyLocalPlayerPosition(x: number, y: number, source: ServerPositionSource): void {
|
||||||
|
if (this.phaseValue === 'replay') {
|
||||||
|
if (x !== 0 && y !== 0) this.predictor.setPosition(x, y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inTown = this.isLocalPlayerInTown()
|
||||||
|
// No loaded level contains the player, i.e. it has no room yet. The gate's hard branch
|
||||||
|
// skips a unit without a room when the server position has none either
|
||||||
|
// (0x6FAB1400 / 0x6FABC2E0), so there is nothing to verify against.
|
||||||
|
if (inTown === null) return
|
||||||
|
const verdict = this.predictor.verifyServerPosition({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
source,
|
||||||
|
rttMs: this.pingMs,
|
||||||
|
inTown,
|
||||||
|
})
|
||||||
|
if (verdict.action === 'request-resync') {
|
||||||
|
this.forwardCommandToAdapter({
|
||||||
|
type: 'UpdatePlayerPos',
|
||||||
|
x: verdict.clientX,
|
||||||
|
y: verdict.clientY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a
|
||||||
|
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
|
||||||
|
* compares, `Math.round` of the logical position (logical N.0 is the centre of sub-tile N).
|
||||||
|
*/
|
||||||
|
private isLocalPlayerInTown(): boolean | null {
|
||||||
|
if (!this.mapService || this.world.areaId <= 0) return null
|
||||||
|
const snap = this.predictor.getSnapshot()
|
||||||
|
const levelView = this.mapService.findLevelViewAtWorldSubTile(
|
||||||
|
Math.round(snap.logicalX),
|
||||||
|
Math.round(snap.logicalY),
|
||||||
|
[this.world.areaId, ...this.world.getRevealedAreaIds()],
|
||||||
|
)
|
||||||
|
return levelView === null ? null : isTownLevel(levelView.areaId)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1080,7 +1134,7 @@ export class OnlineSession {
|
||||||
unitId: u.id,
|
unitId: u.id,
|
||||||
x: u.x,
|
x: u.x,
|
||||||
y: u.y,
|
y: u.y,
|
||||||
...(u.direction !== undefined ? { dir16: u.direction } : {}),
|
...(u.direction !== undefined ? { dir64: u.direction } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
@ -1091,20 +1145,10 @@ export class OnlineSession {
|
||||||
const isLocalPlayer =
|
const isLocalPlayer =
|
||||||
evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0)
|
evTypeId === 0 && (ev.id === this.world.selfId || this.world.selfId === 0)
|
||||||
if (isLocalPlayer) {
|
if (isLocalPlayer) {
|
||||||
const prevSnap = this.predictor.getSnapshot()
|
// 1.13c D2Client 0x6FB5F640 → 0x6FB57E20: `0x15` repositions the player directly, with
|
||||||
if (prevSnap.logicalX === 0 && prevSnap.logicalY === 0) {
|
// no tolerance check whatever its flag byte, and drops the path.
|
||||||
this.predictor.setPosition(ev.at.x, ev.at.y)
|
this.predictor.setPosition(ev.at.x, ev.at.y)
|
||||||
this.camera.snapTo(ev.at.x, ev.at.y)
|
this.camera.snapTo(ev.at.x, ev.at.y)
|
||||||
} else {
|
|
||||||
const res = this.predictor.reconcileServerPosition({
|
|
||||||
x: ev.at.x,
|
|
||||||
y: ev.at.y,
|
|
||||||
teleport: ev.snap,
|
|
||||||
})
|
|
||||||
if (res.mode === 'hard-snap') {
|
|
||||||
this.camera.snapTo(ev.at.x, ev.at.y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.interpolator.spawnOrSnap({
|
this.interpolator.spawnOrSnap({
|
||||||
unitType: evTypeId,
|
unitType: evTypeId,
|
||||||
|
|
@ -1123,10 +1167,15 @@ export class OnlineSession {
|
||||||
this.predictor.setPosition(ev.at.x, ev.at.y)
|
this.predictor.setPosition(ev.at.x, ev.at.y)
|
||||||
this.camera.snapTo(ev.at.x, ev.at.y)
|
this.camera.snapTo(ev.at.x, ev.at.y)
|
||||||
} else {
|
} else {
|
||||||
this.predictor.reconcileServerPosition({
|
if (ev.vx === undefined || ev.vy === undefined) {
|
||||||
x: ev.at.x,
|
throw new Error(
|
||||||
y: ev.at.y,
|
'LifeMana has a position but no vx/vy motion vector (0x18/0x95/0x96 always carry both)',
|
||||||
teleport: false,
|
)
|
||||||
|
}
|
||||||
|
this.verifyLocalPlayerPosition(ev.at.x, ev.at.y, {
|
||||||
|
kind: 'life-mana',
|
||||||
|
vx: ev.vx,
|
||||||
|
vy: ev.vy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1145,11 +1194,7 @@ export class OnlineSession {
|
||||||
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
|
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
|
||||||
if (isLocalPlayer) {
|
if (isLocalPlayer) {
|
||||||
if (ev.mode === 'stop') {
|
if (ev.mode === 'stop') {
|
||||||
this.predictor.reconcileServerPosition({
|
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
|
||||||
x: toX,
|
|
||||||
y: toY,
|
|
||||||
teleport: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else if (ev.mode === 'stop') {
|
} else if (ev.mode === 'stop') {
|
||||||
this.interpolator.stopEntity({
|
this.interpolator.stopEntity({
|
||||||
|
|
@ -1257,7 +1302,7 @@ export class OnlineSession {
|
||||||
if (unit) {
|
if (unit) {
|
||||||
unit.x = state.x
|
unit.x = state.x
|
||||||
unit.y = state.y
|
unit.y = state.y
|
||||||
unit.direction = state.dir16
|
unit.direction = state.dir64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1289,7 +1334,7 @@ export class OnlineSession {
|
||||||
? selfUnit.mode
|
? selfUnit.mode
|
||||||
: 1
|
: 1
|
||||||
const selfDirection =
|
const selfDirection =
|
||||||
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir16
|
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64
|
||||||
out.push({
|
out.push({
|
||||||
id: this.world.selfId,
|
id: this.world.selfId,
|
||||||
kind: 'player',
|
kind: 'player',
|
||||||
|
|
@ -1383,7 +1428,7 @@ export class OnlineSession {
|
||||||
x: ux,
|
x: ux,
|
||||||
y: uy,
|
y: uy,
|
||||||
mode: u.mode,
|
mode: u.mode,
|
||||||
direction: interp ? interp.dir16 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
radiusX: 28,
|
radiusX: 28,
|
||||||
radiusY: 20,
|
radiusY: 20,
|
||||||
})
|
})
|
||||||
|
|
@ -1422,7 +1467,7 @@ export class OnlineSession {
|
||||||
x: ux,
|
x: ux,
|
||||||
y: uy,
|
y: uy,
|
||||||
mode,
|
mode,
|
||||||
direction: interp ? interp.dir16 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||||||
isCorpse: false,
|
isCorpse: false,
|
||||||
states: Array.from(u.states.list()),
|
states: Array.from(u.states.list()),
|
||||||
|
|
@ -1464,7 +1509,7 @@ export class OnlineSession {
|
||||||
x: ux,
|
x: ux,
|
||||||
y: uy,
|
y: uy,
|
||||||
mode,
|
mode,
|
||||||
direction: interp ? interp.dir16 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
hpRatio: 1,
|
hpRatio: 1,
|
||||||
isCorpse: false,
|
isCorpse: false,
|
||||||
...(goldAmount !== undefined ? { goldAmount } : {}),
|
...(goldAmount !== undefined ? { goldAmount } : {}),
|
||||||
|
|
@ -1490,7 +1535,7 @@ export class OnlineSession {
|
||||||
...(targetX !== undefined ? { targetX } : {}),
|
...(targetX !== undefined ? { targetX } : {}),
|
||||||
...(targetY !== undefined ? { targetY } : {}),
|
...(targetY !== undefined ? { targetY } : {}),
|
||||||
mode,
|
mode,
|
||||||
direction: interp ? interp.dir16 : u.direction,
|
direction: interp ? interp.dir64 : u.direction,
|
||||||
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)),
|
||||||
isCorpse,
|
isCorpse,
|
||||||
isChampion: u.isChampion,
|
isChampion: u.isChampion,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
|
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
|
||||||
|
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
|
||||||
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
||||||
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
||||||
import type { UnitCastState } from '../world/client-unit.ts'
|
import type { UnitCastState } from '../world/client-unit.ts'
|
||||||
|
|
@ -35,6 +36,11 @@ export interface RenderableUnitInput {
|
||||||
readonly targetX?: number | undefined
|
readonly targetX?: number | undefined
|
||||||
readonly targetY?: number | undefined
|
readonly targetY?: number | undefined
|
||||||
readonly mode?: number | undefined
|
readonly mode?: number | undefined
|
||||||
|
/**
|
||||||
|
* Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept
|
||||||
|
* by the motion layer; 0 (south) when absent. Missiles: the 16-direction index used when no
|
||||||
|
* target is given.
|
||||||
|
*/
|
||||||
readonly direction?: number | undefined
|
readonly direction?: number | undefined
|
||||||
readonly frame?: number | undefined
|
readonly frame?: number | undefined
|
||||||
readonly lifePct?: number | undefined
|
readonly lifePct?: number | undefined
|
||||||
|
|
@ -79,6 +85,7 @@ export interface SceneUnitDrawable {
|
||||||
readonly itemQuality?: number | undefined
|
readonly itemQuality?: number | undefined
|
||||||
readonly goldAmount?: number | undefined
|
readonly goldAmount?: number | undefined
|
||||||
readonly mode: number
|
readonly mode: number
|
||||||
|
/** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */
|
||||||
readonly direction: number
|
readonly direction: number
|
||||||
readonly frame: number
|
readonly frame: number
|
||||||
readonly subX: number
|
readonly subX: number
|
||||||
|
|
@ -262,6 +269,23 @@ function computeDirection8Or16(
|
||||||
return Math.floor(normalized * numDirs) % numDirs
|
return Math.floor(normalized * numDirs) % numDirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dir64 facing of a player, monster, object or item. The motion layer sets the facing
|
||||||
|
* when a unit starts a segment, so `direction` passes through as is; a unit given a target
|
||||||
|
* (its own, or its active cast's) faces it (D2Common 0x6FD5CF40 via `dir64FromPoints`) and
|
||||||
|
* keeps `direction` when already there.
|
||||||
|
*/
|
||||||
|
function unitFacingDir64(
|
||||||
|
unit: RenderableUnitInput,
|
||||||
|
targetX: number | undefined,
|
||||||
|
targetY: number | undefined,
|
||||||
|
): number {
|
||||||
|
const facing = unit.direction ?? 0
|
||||||
|
assertDir64(facing, `unit ${String(unit.id)} direction`)
|
||||||
|
if (targetX === undefined || targetY === undefined) return facing
|
||||||
|
return dir64FromPoints(unit.x, unit.y, targetX, targetY, facing)
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildSceneDrawablesResult {
|
export interface BuildSceneDrawablesResult {
|
||||||
readonly unitDrawables: readonly SceneUnitDrawable[]
|
readonly unitDrawables: readonly SceneUnitDrawable[]
|
||||||
readonly missileDrawables: readonly SceneMissileDrawable[]
|
readonly missileDrawables: readonly SceneMissileDrawable[]
|
||||||
|
|
@ -484,14 +508,7 @@ export function buildSceneDrawables(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = computeDirection8Or16(
|
const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY)
|
||||||
unit.x,
|
|
||||||
unit.y,
|
|
||||||
effectiveTargetX,
|
|
||||||
effectiveTargetY,
|
|
||||||
unit.direction ?? 0,
|
|
||||||
8,
|
|
||||||
)
|
|
||||||
|
|
||||||
const isWarp = unit.kind === 'warp' || unit.kind === 'tile'
|
const isWarp = unit.kind === 'warp' || unit.kind === 'tile'
|
||||||
const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId
|
const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('predicts 25Hz local movement and reconciles server updates via smooth rubber-band (<=5) and hard-snap (>5)', () => {
|
it('predicts 25Hz local movement and applies the 1.13c server position rules (gate 0x6FADA1B0, 0x15)', () => {
|
||||||
const predictor = new LocalMovementPredictor({
|
const predictor = new LocalMovementPredictor({
|
||||||
initialX: 100,
|
initialX: 100,
|
||||||
initialY: 200,
|
initialY: 200,
|
||||||
|
|
@ -425,29 +425,36 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
|
||||||
expect(after1s.logicalY).toBeCloseTo(200, 4)
|
expect(after1s.logicalY).toBeCloseTo(200, 4)
|
||||||
expect(after1s.moving).toBe(true)
|
expect(after1s.moving).toBe(true)
|
||||||
|
|
||||||
// Reconcile small drift (2.0 sub-tiles <= 5 threshold): rubber-band
|
// Server 2 sub-tiles behind a running player (RN base 7, RTT 0): within tolerance, no move.
|
||||||
const rb = predictor.reconcileServerPosition({ x: 111.5, y: 200 })
|
const within = predictor.verifyServerPosition({
|
||||||
expect(rb.mode).toBe('rubber-band')
|
x: 112,
|
||||||
expect(rb.driftDistance).toBeCloseTo(2.0, 4)
|
y: 200,
|
||||||
// Visual position remains 113.5 immediately after reconcile (no pop), logical is 111.5
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
const snapAfterRb = predictor.getSnapshot()
|
rttMs: 0,
|
||||||
expect(snapAfterRb.logicalX).toBeCloseTo(111.5, 4)
|
inTown: false,
|
||||||
expect(snapAfterRb.x).toBeCloseTo(113.5, 4)
|
})
|
||||||
expect(snapAfterRb.errorOffsetX).toBeCloseTo(2.0, 4)
|
expect(within).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 7 })
|
||||||
|
expect(predictor.getSnapshot().logicalX).toBeCloseTo(113.5, 4)
|
||||||
|
|
||||||
// Stepping ticks decays errorOffsetX toward 0
|
// 10 sub-tiles behind: the client asks for a resync with its own sub-tile and keeps walking.
|
||||||
const decayed = predictor.stepTicks(15)
|
const far = predictor.verifyServerPosition({
|
||||||
expect(Math.abs(decayed.errorOffsetX)).toBeLessThan(0.05)
|
x: 104,
|
||||||
|
y: 200,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
})
|
||||||
|
expect(far).toMatchObject({ action: 'request-resync', reason: 'exceeded', clientX: 114, clientY: 200 })
|
||||||
|
const afterFar = predictor.getSnapshot()
|
||||||
|
expect(afterFar.logicalX).toBeCloseTo(113.5, 4)
|
||||||
|
expect(afterFar.moving).toBe(true)
|
||||||
|
|
||||||
// Reconcile large drift (8.0 sub-tiles > 5 threshold): hard-snap
|
// 0x15 ReassignPlayer repositions directly and drops the path.
|
||||||
const currentLogicalX = predictor.getSnapshot().logicalX
|
predictor.setPosition(104, 200)
|
||||||
const hs = predictor.reconcileServerPosition({ x: currentLogicalX + 8.0, y: 210 })
|
const afterReassign = predictor.getSnapshot()
|
||||||
expect(hs.mode).toBe('hard-snap')
|
expect(afterReassign.logicalX).toBe(104)
|
||||||
const snapAfterHs = predictor.getSnapshot()
|
expect(afterReassign.x).toBe(104)
|
||||||
expect(snapAfterHs.logicalX).toBeCloseTo(currentLogicalX + 8.0, 4)
|
expect(afterReassign.moving).toBe(false)
|
||||||
expect(snapAfterHs.x).toBeCloseTo(currentLogicalX + 8.0, 4)
|
|
||||||
expect(snapAfterHs.errorOffsetX).toBe(0)
|
|
||||||
expect(snapAfterHs.moving).toBe(false)
|
|
||||||
|
|
||||||
// Walk speed verification
|
// Walk speed verification
|
||||||
predictor.setRunning(false)
|
predictor.setRunning(false)
|
||||||
|
|
|
||||||
|
|
@ -717,6 +717,8 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
mana: 100,
|
mana: 100,
|
||||||
stamina: 100,
|
stamina: 100,
|
||||||
at: { x: 5100, y: 5100 },
|
at: { x: 5100, y: 5100 },
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
})
|
})
|
||||||
session.handleServerEvent({
|
session.handleServerEvent({
|
||||||
type: 'Merc',
|
type: 'Merc',
|
||||||
|
|
@ -924,15 +926,20 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
life: 320,
|
life: 320,
|
||||||
mana: 410,
|
mana: 410,
|
||||||
stamina: 190,
|
stamina: 190,
|
||||||
at: { x: 502, y: 501 }, // drift <= 5 -> rubber-band
|
at: { x: 502, y: 501 },
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(session.world.areaId).toBe(1)
|
expect(session.world.areaId).toBe(1)
|
||||||
expect(session.hudManager.hp).toBe(320)
|
expect(session.hudManager.hp).toBe(320)
|
||||||
expect(session.hudManager.mana).toBe(410)
|
expect(session.hudManager.mana).toBe(410)
|
||||||
expect(session.predictor.getSnapshot().logicalX).toBe(502)
|
// 1.13c never moves the local player toward a 0x95 position (D2Client gate 0x6FADA1B0); a
|
||||||
expect(session.predictor.getSnapshot().logicalY).toBe(501)
|
// 2-sub-tile drift needs no resync either.
|
||||||
|
expect(session.predictor.getSnapshot().logicalX).toBe(500)
|
||||||
|
expect(session.predictor.getSnapshot().logicalY).toBe(500)
|
||||||
|
expect(sentAdapterCommands.some(cmd => cmd.type === 'UpdatePlayerPos')).toBe(false)
|
||||||
|
|
||||||
// Step frame tick and build SceneSource snapshot
|
// Step frame tick and build SceneSource snapshot
|
||||||
session.tick(40, 1000)
|
session.tick(40, 1000)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
/**
|
||||||
|
* Local player server positions in `OnlineSession` (D2Client 1.13c).
|
||||||
|
*
|
||||||
|
* `0x18` / `0x95` / `0x96` (LifeMana with a position) and a `0x0D` stop of the local player go
|
||||||
|
* through the position gate 0x6FADA1B0: the player is never moved toward them, and when the
|
||||||
|
* drift is out of tolerance the client sends C2S `0x5F` with its own sub-tile. `0x15` moves the
|
||||||
|
* player directly. A `.d2cap` replay has no server to answer `0x5F`, so it takes the captured
|
||||||
|
* positions as they are.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { MapService } from '../../src/client/map/map-service.ts'
|
||||||
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||||
|
import { createSettingsStore } from '../../src/client/settings/settings-store.ts'
|
||||||
|
import { formatD2CapHeader, type ClientCommand, type ServerEvent } from '../../src/netproto/index.ts'
|
||||||
|
|
||||||
|
const ROGUE_ENCAMPMENT = 1
|
||||||
|
const BLOOD_MOOR = 2
|
||||||
|
|
||||||
|
/** A map service whose only loaded level is `areaId`, covering every sub-tile (or none). */
|
||||||
|
function stubMapService(areaId: number | null): MapService {
|
||||||
|
return {
|
||||||
|
onLoadActPacket: () => null,
|
||||||
|
onAddRoomDataPacket: () => null,
|
||||||
|
onRemoveRoomDataPacket: () => null,
|
||||||
|
getActiveLevelView: () => null,
|
||||||
|
getTileAtlas: () => null,
|
||||||
|
findLevelViewAtWorldSubTile: (_x: number, _y: number, areaIds: Iterable<number>) =>
|
||||||
|
areaId !== null && [...areaIds].includes(areaId) ? { areaId } : null,
|
||||||
|
} as unknown as MapService
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session with the local player (id 1) assigned at (5000, 5000) in `areaId`. */
|
||||||
|
function sessionInLevel(areaId: number, loadedLevel: number | null = areaId): OnlineSession {
|
||||||
|
const session = new OnlineSession({
|
||||||
|
settingsStore: createSettingsStore(),
|
||||||
|
mapService: stubMapService(loadedLevel),
|
||||||
|
})
|
||||||
|
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x1234, areaId })
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 'player', id: 1, classId: 1, name: 'gate', x: 5000, y: 5000 },
|
||||||
|
isSelf: true,
|
||||||
|
})
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifeMana(x: number, y: number, vx = 0, vy = 0): ServerEvent {
|
||||||
|
return { type: 'LifeMana', life: 100, mana: 100, stamina: 100, at: { x, y }, vx, vy }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resyncRequests(session: OnlineSession): ClientCommand[] {
|
||||||
|
return session.getSentCommands().filter(cmd => cmd.type === 'UpdatePlayerPos')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OnlineSession local player position gate (D2Client 0x6FADA1B0)', () => {
|
||||||
|
it('asks for a resync with its own sub-tile when a 0x95 position is out of tolerance', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR)
|
||||||
|
session.handleServerEvent(lifeMana(5003, 5000)) // NU tolerance 3
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
|
||||||
|
session.handleServerEvent(lifeMana(5004, 5000))
|
||||||
|
expect(resyncRequests(session)).toEqual([{ type: 'UpdatePlayerPos', x: 5000, y: 5000 }])
|
||||||
|
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5000, logicalY: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the town standing tolerance (TN 5) in a town level', () => {
|
||||||
|
const session = sessionInLevel(ROGUE_ENCAMPMENT)
|
||||||
|
session.handleServerEvent(lifeMana(5005, 4995))
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
session.handleServerEvent(lifeMana(5006, 5000))
|
||||||
|
expect(resyncRequests(session)).toEqual([{ type: 'UpdatePlayerPos', x: 5000, y: 5000 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('widens the tolerance by the 0x8F round trip: (rtt + 50) >> 7', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR)
|
||||||
|
session.handleServerEvent({ type: 'Pong', rttMs: 78, tickCount: 0 })
|
||||||
|
session.handleServerEvent(lifeMana(5004, 5000)) // 3 + 1
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
session.handleServerEvent(lifeMana(5005, 5000))
|
||||||
|
expect(resyncRequests(session)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not ask while the client leads the server along its motion vector', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR)
|
||||||
|
// The server is 5 sub-tiles behind but heading toward the client.
|
||||||
|
session.handleServerEvent(lifeMana(4995, 5000, 3, 0))
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
session.handleServerEvent(lifeMana(4995, 5000, -3, 0))
|
||||||
|
expect(resyncRequests(session)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks a 0x0D stop of the local player with the flag-1 tolerance of 10', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR)
|
||||||
|
const stop = (x: number): ServerEvent => ({
|
||||||
|
type: 'UnitMove',
|
||||||
|
unitType: 'player',
|
||||||
|
id: 1,
|
||||||
|
mode: 'stop',
|
||||||
|
to: { x, y: 5000 },
|
||||||
|
})
|
||||||
|
session.handleServerEvent(stop(5010))
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
session.handleServerEvent(stop(5011))
|
||||||
|
expect(resyncRequests(session)).toEqual([{ type: 'UpdatePlayerPos', x: 5000, y: 5000 }])
|
||||||
|
expect(session.predictor.getSnapshot().logicalX).toBe(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the check while no loaded level contains the player', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR, null)
|
||||||
|
session.handleServerEvent(lifeMana(5100, 5100))
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5000, logicalY: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast on a LifeMana position without its motion vector', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR)
|
||||||
|
expect(() =>
|
||||||
|
session.handleServerEvent({ type: 'LifeMana', life: 1, mana: 1, stamina: 1, at: { x: 5004, y: 5000 } }),
|
||||||
|
).toThrow(/vx\/vy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves the player directly on 0x15 and keeps its facing', () => {
|
||||||
|
const session = sessionInLevel(BLOOD_MOOR)
|
||||||
|
session.predictor.setDirection(40)
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'UnitReassign',
|
||||||
|
unitType: 'player',
|
||||||
|
id: 1,
|
||||||
|
at: { x: 5100, y: 5120 },
|
||||||
|
snap: false,
|
||||||
|
})
|
||||||
|
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5100, logicalY: 5120, dir64: 40 })
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows captured positions in a .d2cap replay, which has no server to answer 0x5F', () => {
|
||||||
|
const session = new OnlineSession({
|
||||||
|
settingsStore: createSettingsStore(),
|
||||||
|
mapService: stubMapService(BLOOD_MOOR),
|
||||||
|
})
|
||||||
|
session.loadD2CapReplay(
|
||||||
|
formatD2CapHeader({
|
||||||
|
format: 'd2cap-v1',
|
||||||
|
createdAt: '2026-10-02T00:00:00Z',
|
||||||
|
server: '127.0.0.1',
|
||||||
|
account: 'gate',
|
||||||
|
character: 'gate',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(session.phase).toBe('replay')
|
||||||
|
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x1234, areaId: BLOOD_MOOR })
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 'player', id: 1, classId: 1, name: 'gate', x: 5000, y: 5000 },
|
||||||
|
isSelf: true,
|
||||||
|
})
|
||||||
|
session.handleServerEvent(lifeMana(5040, 5010, 4, 1))
|
||||||
|
expect(session.predictor.getSnapshot()).toMatchObject({ logicalX: 5040, logicalY: 5010 })
|
||||||
|
expect(resyncRequests(session)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -149,7 +149,8 @@ describe('Issue #539: player spawn is rendered at the server position inside the
|
||||||
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
|
const session = new OnlineSession({ settingsStore: createSettingsStore(), mapService })
|
||||||
session.loadD2CapReplay(text)
|
session.loadD2CapReplay(text)
|
||||||
session.replayAllD2Cap()
|
session.replayAllD2Cap()
|
||||||
// Let any rubber-band reconciliation settle; no movement commands are issued.
|
// Replay places the local player at each captured position; tick so the camera follows.
|
||||||
|
// No movement commands are issued.
|
||||||
for (let i = 0; i < 50; i++) session.tick(40, 1000 + i * 40)
|
for (let i = 0; i < 50; i++) session.tick(40, 1000 + i * 40)
|
||||||
expect(session.disconnectReason).toBeNull()
|
expect(session.disconnectReason).toBeNull()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
/**
|
||||||
|
* Unit facing while moving (dir64, `src/common/units/direction.ts`).
|
||||||
|
*
|
||||||
|
* D2 1.13c sets a unit's facing once, when it starts a segment toward its next path point
|
||||||
|
* (D2Common 0x6FD5D240), and draws it through D2CMP #10020. These tests pin the client's
|
||||||
|
* local predictor and remote interpolator to that rule: the facing must match the movement
|
||||||
|
* direction, must not change in the middle of a segment, and must survive re-pathing while
|
||||||
|
* the mouse is held, idle frames and server repositioning.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { CommandMapper } from '../../src/client/input/command-mapper.ts'
|
||||||
|
import type { MapService } from '../../src/client/map/map-service.ts'
|
||||||
|
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
|
||||||
|
import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts'
|
||||||
|
import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts'
|
||||||
|
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
||||||
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||||
|
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts'
|
||||||
|
import type { ClientCommand } from '../../src/netproto/domain/client-command.ts'
|
||||||
|
|
||||||
|
const FRAME_MS = 1000 / 60
|
||||||
|
|
||||||
|
/** dir64 for the 16 screen headings (0 = screen south, clockwise), as the engine quantises them. */
|
||||||
|
const HEADING_DIR64 = [0, 4, 7, 11, 15, 20, 23, 27, 32, 36, 40, 43, 47, 52, 56, 59] as const
|
||||||
|
|
||||||
|
/** World unit vector of screen heading `k`: screen south is world (+1, +1), 22.5° per step. */
|
||||||
|
function headingVector(k: number): readonly [number, number] {
|
||||||
|
const radians = ((45 + k * 22.5) * Math.PI) / 180
|
||||||
|
return [Math.cos(radians), Math.sin(radians)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A walkable world sub-tile grid with its origin at (`originX`, `originY`). */
|
||||||
|
function makeGrid(
|
||||||
|
originX: number,
|
||||||
|
originY: number,
|
||||||
|
size: number,
|
||||||
|
blocked: (x: number, y: number) => boolean = () => false,
|
||||||
|
): SubTileWalkabilityGrid {
|
||||||
|
const walkable = new Uint8Array(size * size)
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
walkable[y * size + x] = blocked(originX + x, originY + y) ? 0 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { subWidth: size, subHeight: size, walkable, subOriginX: originX, subOriginY: originY }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production input path: `CommandMapper` re-paths from the player's cell on every frame the
|
||||||
|
* left button is held (`triggerPrimaryWorldAction` → `startLocalPathPrediction`).
|
||||||
|
*/
|
||||||
|
function makeMapper(grid: SubTileWalkabilityGrid, predictor: LocalMovementPredictor): CommandMapper {
|
||||||
|
const hudManager = new HudManager()
|
||||||
|
hudManager.resize(800, 600)
|
||||||
|
const mapService = { getActiveLevelView: () => grid } as unknown as MapService
|
||||||
|
const sent: ClientCommand[] = []
|
||||||
|
return new CommandMapper({
|
||||||
|
world: new ClientWorld(),
|
||||||
|
hudManager,
|
||||||
|
predictor,
|
||||||
|
mapService,
|
||||||
|
sendCommand: cmd => sent.push(cmd),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('local player facing (LocalMovementPredictor)', () => {
|
||||||
|
it('faces each of the 16 screen headings and draws the matching clip direction', () => {
|
||||||
|
for (let k = 0; k < 16; k++) {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 })
|
||||||
|
const [dx, dy] = headingVector(k)
|
||||||
|
predictor.setPath([{ x: 1000 + 20 * dx, y: 1000 + 20 * dy }], true)
|
||||||
|
const { dir64 } = predictor.getSnapshot()
|
||||||
|
expect(dir64, `heading ${k}`).toBe(HEADING_DIR64[k])
|
||||||
|
expect(dir64ToClipDirection(dir64, 16), `heading ${k}`).toBe(k)
|
||||||
|
if (k % 2 === 0) expect(dir64ToClipDirection(dir64, 8), `heading ${k}`).toBe(k / 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one facing and always moves forward while the mouse is held (re-path every frame)', () => {
|
||||||
|
const grid = makeGrid(4900, 4900, 200)
|
||||||
|
for (let k = 0; k < 16; k++) {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
|
||||||
|
const mapper = makeMapper(grid, predictor)
|
||||||
|
const [dx, dy] = headingVector(k)
|
||||||
|
const goalX = 5000 + Math.round(30 * dx)
|
||||||
|
const goalY = 5000 + Math.round(30 * dy)
|
||||||
|
const expectedDir64 = dir64FromPoints(5000, 5000, goalX, goalY, 0)
|
||||||
|
|
||||||
|
let previousDistance = Number.POSITIVE_INFINITY
|
||||||
|
let frames = 0
|
||||||
|
for (; frames < 600; frames++) {
|
||||||
|
mapper.triggerPrimaryWorldAction(goalX, goalY, null, frames * FRAME_MS)
|
||||||
|
const snap = predictor.update(FRAME_MS)
|
||||||
|
expect(snap.dir64, `heading ${k} frame ${frames}`).toBe(expectedDir64)
|
||||||
|
if (!snap.moving) break
|
||||||
|
const distance = Math.hypot(goalX - snap.logicalX, goalY - snap.logicalY)
|
||||||
|
expect(distance, `heading ${k} frame ${frames}`).toBeLessThan(previousDistance)
|
||||||
|
previousDistance = distance
|
||||||
|
}
|
||||||
|
expect(frames, `heading ${k}`).toBeLessThan(600)
|
||||||
|
// The player stops inside the goal cell: 1.13c path points never include the unit's cell.
|
||||||
|
const stopped = predictor.getSnapshot()
|
||||||
|
expect(Math.round(stopped.logicalX)).toBe(goalX)
|
||||||
|
expect(Math.round(stopped.logicalY)).toBe(goalY)
|
||||||
|
|
||||||
|
// Holding the button at the goal changes nothing.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
mapper.triggerPrimaryWorldAction(goalX, goalY, null, (frames + i) * FRAME_MS)
|
||||||
|
const idle = predictor.update(FRAME_MS)
|
||||||
|
expect(idle).toMatchObject({ moving: false, dir64: expectedDir64 })
|
||||||
|
expect(idle.logicalX).toBe(stopped.logicalX)
|
||||||
|
expect(idle.logicalY).toBe(stopped.logicalY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never steps back to the cell centre when a new click arrives mid-cell', () => {
|
||||||
|
const grid = makeGrid(4900, 4900, 200)
|
||||||
|
const cases = [
|
||||||
|
{ runTo: { x: 5030, y: 5000 }, travelled: 4.3, clickAt: { x: 5020, y: 5010 } },
|
||||||
|
{ runTo: { x: 5030, y: 5000 }, travelled: 4.7, clickAt: { x: 5025, y: 4990 } },
|
||||||
|
{ runTo: { x: 4970, y: 5000 }, travelled: 4.3, clickAt: { x: 4980, y: 5010 } },
|
||||||
|
{ runTo: { x: 5000, y: 5030 }, travelled: 2.2, clickAt: { x: 4990, y: 5020 } },
|
||||||
|
]
|
||||||
|
for (const { runTo, travelled, clickAt } of cases) {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
|
||||||
|
const mapper = makeMapper(grid, predictor)
|
||||||
|
mapper.triggerPrimaryWorldAction(runTo.x, runTo.y, null, 0)
|
||||||
|
const speed = predictor.getEffectiveSpeedSubTilesPerSec()
|
||||||
|
const before = predictor.update((travelled / speed) * 1000)
|
||||||
|
expect(Math.hypot(before.logicalX - 5000, before.logicalY - 5000)).toBeCloseTo(travelled, 6)
|
||||||
|
|
||||||
|
mapper.triggerPrimaryWorldAction(clickAt.x, clickAt.y, null, 1000)
|
||||||
|
const turned = predictor.getSnapshot()
|
||||||
|
expect(turned.dir64).toBe(
|
||||||
|
dir64FromPoints(before.logicalX, before.logicalY, clickAt.x, clickAt.y, 0),
|
||||||
|
)
|
||||||
|
const after = predictor.update(FRAME_MS)
|
||||||
|
const toGoalX = clickAt.x - before.logicalX
|
||||||
|
const toGoalY = clickAt.y - before.logicalY
|
||||||
|
const stepX = after.logicalX - before.logicalX
|
||||||
|
const stepY = after.logicalY - before.logicalY
|
||||||
|
// The first step heads straight for the new goal, not back to the cell the path started in.
|
||||||
|
const cosine = (stepX * toGoalX + stepY * toGoalY) / (Math.hypot(stepX, stepY) * Math.hypot(toGoalX, toGoalY))
|
||||||
|
expect(cosine).toBeCloseTo(1, 9)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets the facing only when a segment starts', () => {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
||||||
|
predictor.setPath(
|
||||||
|
[
|
||||||
|
{ x: 110, y: 100 },
|
||||||
|
{ x: 110, y: 110 },
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(predictor.getSnapshot().dir64).toBe(56) // world +X is screen south-east
|
||||||
|
const seen: Array<{ waypointX: number; waypointY: number; dir64: number }> = []
|
||||||
|
for (let frame = 0; frame < 200; frame++) {
|
||||||
|
const snap = predictor.update(FRAME_MS)
|
||||||
|
if (!snap.moving) break
|
||||||
|
seen.push({ waypointX: snap.currentWaypoint!.x, waypointY: snap.currentWaypoint!.y, dir64: snap.dir64 })
|
||||||
|
}
|
||||||
|
expect(seen.length).toBeGreaterThan(10)
|
||||||
|
for (const step of seen) {
|
||||||
|
expect(step.dir64).toBe(step.waypointY === 100 ? 56 : 7) // world +Y is screen south-west
|
||||||
|
}
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 110, logicalY: 110, dir64: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the facing while idle, on an empty path and on a 0x15 reposition', () => {
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
||||||
|
predictor.setPath([{ x: 90, y: 100 }], false)
|
||||||
|
expect(predictor.getSnapshot().dir64).toBe(23) // world −X is screen north-west
|
||||||
|
predictor.update(5000)
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ moving: false, dir64: 23 })
|
||||||
|
|
||||||
|
predictor.update(1000)
|
||||||
|
predictor.setPath([], true)
|
||||||
|
predictor.setPath([{ x: 90, y: 100 }], true) // only the player's own cell
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ moving: false, logicalX: 90, dir64: 23 })
|
||||||
|
|
||||||
|
predictor.setPosition(300, 400)
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 300, logicalY: 400, moving: false, dir64: 23 })
|
||||||
|
|
||||||
|
predictor.setDirection(40)
|
||||||
|
expect(predictor.getSnapshot().dir64).toBe(40)
|
||||||
|
expect(() => predictor.setDirection(64)).toThrow(RangeError)
|
||||||
|
expect(() => predictor.setDirection(-1)).toThrow(RangeError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paths from the player cell in moveToWorldSubTile, like CommandMapper', () => {
|
||||||
|
const grid = makeGrid(4900, 4900, 200)
|
||||||
|
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
|
||||||
|
predictor.setPath([{ x: 5030, y: 5000 }], true)
|
||||||
|
const at = predictor.update((4.7 / predictor.getEffectiveSpeedSubTilesPerSec()) * 1000)
|
||||||
|
expect(at.logicalX).toBeCloseTo(5004.7, 6)
|
||||||
|
|
||||||
|
// findPathWorld floors its start (cell 5004); the predictor must path from cell 5005.
|
||||||
|
const remaining = predictor.moveToWorldSubTile(grid, 5030, 5000)
|
||||||
|
expect(remaining).toEqual([{ x: 5030, y: 5000 }])
|
||||||
|
const next = predictor.update(FRAME_MS)
|
||||||
|
expect(next.logicalX).toBeGreaterThan(at.logicalX)
|
||||||
|
expect(next.dir64).toBe(56)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('remote unit facing (RemoteEntityInterpolator)', () => {
|
||||||
|
it('faces the 16 screen headings when a move starts and keeps the facing to the end', () => {
|
||||||
|
const interp = new RemoteEntityInterpolator()
|
||||||
|
for (let k = 0; k < 16; k++) {
|
||||||
|
interp.spawnOrSnap({ unitType: 1, unitId: k, x: 5000, y: 5000 })
|
||||||
|
const [dx, dy] = headingVector(k)
|
||||||
|
const started = interp.startMove({
|
||||||
|
unitType: 1,
|
||||||
|
unitId: k,
|
||||||
|
currentX: 5000,
|
||||||
|
currentY: 5000,
|
||||||
|
targetX: 5000 + 10 * dx,
|
||||||
|
targetY: 5000 + 10 * dy,
|
||||||
|
running: true,
|
||||||
|
})
|
||||||
|
expect(started.dir64, `heading ${k}`).toBe(HEADING_DIR64[k])
|
||||||
|
}
|
||||||
|
for (let frame = 0; frame < 120; frame++) {
|
||||||
|
interp.update(FRAME_MS)
|
||||||
|
for (let k = 0; k < 16; k++) {
|
||||||
|
expect(interp.getEntityState(1, k)!.dir64, `heading ${k} frame ${frame}`).toBe(HEADING_DIR64[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(interp.getAllStates().every(s => !s.moving)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the facing on a reassign without a direction and starts new units facing dir64 0', () => {
|
||||||
|
const interp = new RemoteEntityInterpolator()
|
||||||
|
interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 100, y: 100, dir64: 40 })
|
||||||
|
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130 }).dir64).toBe(40)
|
||||||
|
expect(interp.spawnOrSnap({ unitType: 0, unitId: 7, x: 120, y: 130, dir64: 12 }).dir64).toBe(12)
|
||||||
|
expect(interp.spawnOrSnap({ unitType: 0, unitId: 8, x: 100, y: 100 }).dir64).toBe(0)
|
||||||
|
expect(() => interp.spawnOrSnap({ unitType: 0, unitId: 9, x: 1, y: 1, dir64: 64 })).toThrow(RangeError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faces the stop point while gliding to it and keeps the facing on a hard stop', () => {
|
||||||
|
const interp = new RemoteEntityInterpolator()
|
||||||
|
interp.spawnOrSnap({ unitType: 1, unitId: 1, x: 5000, y: 5000 })
|
||||||
|
expect(interp.startMove({ unitType: 1, unitId: 1, targetX: 5010, targetY: 5000 }).dir64).toBe(56)
|
||||||
|
interp.update(200)
|
||||||
|
const mid = interp.getEntityState(1, 1)!
|
||||||
|
expect(mid.dir64).toBe(56)
|
||||||
|
|
||||||
|
const glide = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003, y: 5002 })
|
||||||
|
expect(glide.moving).toBe(true)
|
||||||
|
expect(glide.dir64).toBe(dir64FromPoints(mid.x, mid.y, 5003, 5002, 56))
|
||||||
|
interp.update(1000)
|
||||||
|
expect(interp.getEntityState(1, 1)).toMatchObject({ x: 5003, y: 5002, moving: false, dir64: glide.dir64 })
|
||||||
|
|
||||||
|
// Within 0.15 sub-tiles the unit snaps and keeps its facing.
|
||||||
|
const snapped = interp.stopEntity({ unitType: 1, unitId: 1, x: 5003.1, y: 5002 })
|
||||||
|
expect(snapped).toMatchObject({ x: 5003.1, moving: false, dir64: glide.dir64 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1252,16 +1252,31 @@ describe('Tier 1 — F13: Client Movement Prediction, Server Reconciliation & Pa
|
||||||
expect(snap.x).toBeLessThanOrEqual(120)
|
expect(snap.x).toBeLessThanOrEqual(120)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('F13.3: LocalMovementPredictor reconciles small server drift (<=5 sub-tiles) via rubber-band', () => {
|
it('F13.3: LocalMovementPredictor keeps a server position within the 1.13c tolerance (gate 0x6FADA1B0)', () => {
|
||||||
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
||||||
const res = predictor.reconcileServerPosition({ x: 103, y: 100 })
|
const res = predictor.verifyServerPosition({
|
||||||
expect(res.mode).toBe('rubber-band')
|
x: 103,
|
||||||
|
y: 100,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
})
|
||||||
|
expect(res).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 3 })
|
||||||
|
expect(predictor.getSnapshot().x).toBe(100)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('F13.4: LocalMovementPredictor reconciles large server drift (>5 sub-tiles) or 0x15 snap via hard-snap', () => {
|
it('F13.4: LocalMovementPredictor asks for a 0x5F resync on large drift and 0x15 repositions', () => {
|
||||||
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
const predictor = new LocalMovementPredictor({ initialX: 100, initialY: 100 })
|
||||||
const res = predictor.reconcileServerPosition({ x: 200, y: 200 }, true)
|
const res = predictor.verifyServerPosition({
|
||||||
expect(res.mode).toBe('hard-snap')
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: false,
|
||||||
|
})
|
||||||
|
expect(res).toMatchObject({ action: 'request-resync', reason: 'exceeded', clientX: 100, clientY: 100 })
|
||||||
|
expect(predictor.stepTicks(0).x).toBe(100)
|
||||||
|
predictor.setPosition(200, 200)
|
||||||
expect(predictor.stepTicks(0).x).toBe(200)
|
expect(predictor.stepTicks(0).x).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -692,9 +692,11 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
||||||
w15Small.u16LE(5050)
|
w15Small.u16LE(5050)
|
||||||
w15Small.u8(0)
|
w15Small.u8(0)
|
||||||
const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables })
|
const evSmall = decodeD2gsS2cPacket(w15Small.toUint8Array(), { tables: itemTables })
|
||||||
|
expect(evSmall.type).toBe('UnitReassign')
|
||||||
if (evSmall.type === 'UnitReassign') {
|
if (evSmall.type === 'UnitReassign') {
|
||||||
const rec = predictor.reconcileServerPosition(evSmall.at, evSmall.snap)
|
// 1.13c `0x15` repositions the player directly, however small the drift (D2Client 0x6FB57E20).
|
||||||
expect(rec.mode).toBe('rubber-band')
|
predictor.setPosition(evSmall.at.x, evSmall.at.y)
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ x: 5103, y: 5050, moving: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const w0f = new ByteWriter()
|
const w0f = new ByteWriter()
|
||||||
|
|
@ -721,7 +723,10 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
|
||||||
moveType: 0x17,
|
moveType: 0x17,
|
||||||
})
|
})
|
||||||
interp.update(200)
|
interp.update(200)
|
||||||
expect(interp.getEntityState(0, 2)!.x).toBeGreaterThan(5200)
|
const mover = interp.getEntityState(0, 2)!
|
||||||
|
expect(mover.x).toBeGreaterThan(5200)
|
||||||
|
// World +X is screen south-east: dir64 56 (D2Common 0x6FD5CF40).
|
||||||
|
expect(mover.dir64).toBe(56)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,10 +244,17 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
|
||||||
}
|
}
|
||||||
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThan(0)
|
expect(automap.buildSnapshot().visibleRoomCount).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Reconcile predictor and play Act 1 Town BGM
|
// Check a server position with the 1.13c gate (D2Client 0x6FADA1B0) and play Act 1 Town BGM
|
||||||
const predictor = new LocalMovementPredictor({ initialX: world.self.x, initialY: world.self.y })
|
const predictor = new LocalMovementPredictor({ initialX: world.self.x, initialY: world.self.y })
|
||||||
const rec = predictor.reconcileServerPosition({ x: world.self.x + 2, y: world.self.y })
|
const verdict = predictor.verifyServerPosition({
|
||||||
expect(rec.mode).toBe('rubber-band')
|
x: world.self.x + 2,
|
||||||
|
y: world.self.y,
|
||||||
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
||||||
|
rttMs: 0,
|
||||||
|
inTown: true,
|
||||||
|
})
|
||||||
|
// Standing in town is TN, whose tolerance base is 5.
|
||||||
|
expect(verdict).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 5 })
|
||||||
|
|
||||||
const playedBgm: number[] = []
|
const playedBgm: number[] = []
|
||||||
const playedSfx: string[] = []
|
const playedSfx: string[] = []
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,8 @@ 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,
|
||||||
LocalMovementPredictor,
|
LocalMovementPredictor,
|
||||||
deltaToDir16,
|
|
||||||
} from '../../src/client/motion/predictor.ts'
|
} from '../../src/client/motion/predictor.ts'
|
||||||
|
import { dir64FromPoints } from '../../src/common/units/direction.ts'
|
||||||
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||||
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -1585,15 +1585,11 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
|
||||||
expect(worldRes.path.length).toBeGreaterThanOrEqual(3)
|
expect(worldRes.path.length).toBeGreaterThanOrEqual(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adv_18_predictor_rubber_band_vs_hard_snap_boundary_and_frw_diminishing_returns', () => {
|
it('adv_18_predictor_server_position_gate_boundaries_and_frw_diminishing_returns', () => {
|
||||||
const predictor = new LocalMovementPredictor({
|
const predictor = new LocalMovementPredictor({ initialX: 5000, initialY: 5000 })
|
||||||
initialX: 5000,
|
|
||||||
initialY: 5000,
|
|
||||||
rubberBandThreshold: 5.0,
|
|
||||||
rubberBandCorrectionRate: 12.0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 1. Start a multi-waypoint path
|
// 1. Start a multi-waypoint path; the leading point in the player's own cell is dropped and
|
||||||
|
// the player faces world +X (screen south-east, dir64 56).
|
||||||
predictor.setPath(
|
predictor.setPath(
|
||||||
[
|
[
|
||||||
{ x: 5000, y: 5000 },
|
{ x: 5000, y: 5000 },
|
||||||
|
|
@ -1604,41 +1600,51 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
|
||||||
)
|
)
|
||||||
expect(predictor.getSnapshot().moving).toBe(true)
|
expect(predictor.getSnapshot().moving).toBe(true)
|
||||||
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
|
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
|
||||||
|
expect(predictor.getSnapshot().dir64).toBe(56)
|
||||||
|
|
||||||
// 2. Server WalkVerify at drift = 4.99 sub-tiles (<= 5.0 threshold) -> rubber-band mode, path preserved!
|
const lifeMana = (x: number, y: number, vx = 0, vy = 0, rttMs = 0) =>
|
||||||
const rBand = predictor.reconcileServerPosition({ x: 5004.99, y: 5000, teleport: false })
|
predictor.verifyServerPosition({ x, y, source: { kind: 'life-mana', vx, vy }, rttMs, inTown: false })
|
||||||
expect(rBand.mode).toBe('rubber-band')
|
|
||||||
|
// 2. Running (RN base 7), RTT 0: 7 sub-tiles is within, 8 asks for a 0x5F resync, and the client
|
||||||
|
// neither moves nor drops its path.
|
||||||
|
expect(lifeMana(5007, 5000)).toMatchObject({ action: 'none', reason: 'within-threshold', threshold: 7 })
|
||||||
|
expect(lifeMana(5008, 5000)).toMatchObject({
|
||||||
|
action: 'request-resync',
|
||||||
|
reason: 'exceeded',
|
||||||
|
clientX: 5000,
|
||||||
|
clientY: 5000,
|
||||||
|
})
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 5000, logicalY: 5000, moving: true })
|
||||||
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
|
expect(predictor.getSnapshot().remainingWaypoints.length).toBe(2)
|
||||||
const snapBeforeDecay = predictor.getSnapshot()
|
|
||||||
expect(Math.abs(snapBeforeDecay.errorOffsetX)).toBeGreaterThan(4.0)
|
|
||||||
|
|
||||||
// Step 500ms -> rubber-band offset decays exponentially toward 0
|
// 3. RTT term `(rtt + 50) >>> 7`: 77 ms adds nothing, 78 ms adds 1.
|
||||||
predictor.update(500)
|
expect(lifeMana(5008, 5000, 0, 0, 77).action).toBe('request-resync')
|
||||||
const snapAfterDecay = predictor.getSnapshot()
|
expect(lifeMana(5008, 5000, 0, 0, 78)).toMatchObject({ action: 'none', threshold: 8 })
|
||||||
expect(Math.abs(snapAfterDecay.errorOffsetX)).toBeLessThan(0.1)
|
|
||||||
|
|
||||||
// 3. Server ReassignPlayer at drift = 5.01 sub-tiles (> 5.0 threshold) -> hard-snap mode, clears path!
|
// 4. Client ahead along the server unit's motion: exempt while d²(client, server) < 100.
|
||||||
const curLogical = predictor.getSnapshot()
|
expect(lifeMana(4992, 5000, 4, 0).reason).toBe('leading-server')
|
||||||
const rSnap = predictor.reconcileServerPosition({
|
expect(lifeMana(4990, 5000, 4, 0).reason).toBe('exceeded')
|
||||||
x: curLogical.logicalX + 5.01,
|
|
||||||
y: curLogical.logicalY,
|
|
||||||
teleport: false,
|
|
||||||
})
|
|
||||||
expect(rSnap.mode).toBe('hard-snap')
|
|
||||||
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
|
|
||||||
expect(predictor.getSnapshot().errorOffsetX).toBe(0)
|
|
||||||
|
|
||||||
// 4. Teleport flag (`teleport: true`) forces hard-snap even when drift is only 1.0 sub-tile
|
// 5. X or Y of 0 carries no position.
|
||||||
predictor.setPath([{ x: 5050, y: 5000 }], true)
|
expect(lifeMana(0, 5000).reason).toBe('no-position')
|
||||||
const rTele = predictor.reconcileServerPosition({
|
|
||||||
x: predictor.getSnapshot().logicalX + 1.0,
|
// 6. `0x0D` for the local player passes gate flag 1: tolerance 10 whatever the mode and RTT.
|
||||||
y: predictor.getSnapshot().logicalY,
|
const stop = (x: number) =>
|
||||||
teleport: true,
|
predictor.verifyServerPosition({ x, y: 5000, source: { kind: 'player-stop' }, rttMs: 500, inTown: false })
|
||||||
})
|
expect(stop(5010)).toMatchObject({ action: 'none', threshold: 10 })
|
||||||
expect(rTele.mode).toBe('hard-snap')
|
expect(stop(5011).action).toBe('request-resync')
|
||||||
|
|
||||||
|
// 7. Malformed input fails fast.
|
||||||
|
expect(() => lifeMana(65536, 5000)).toThrow(RangeError)
|
||||||
|
expect(() => lifeMana(5000, 5000, 0.5, 0)).toThrow(RangeError)
|
||||||
|
expect(() => lifeMana(5000, 5000, 0, 0, -1)).toThrow(RangeError)
|
||||||
|
|
||||||
|
// 8. `0x15 ReassignPlayer` repositions directly, clears the path and keeps the facing.
|
||||||
|
predictor.setPosition(5005, 5000)
|
||||||
|
expect(predictor.getSnapshot()).toMatchObject({ logicalX: 5005, logicalY: 5000, moving: false, dir64: 56 })
|
||||||
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
|
expect(predictor.getSnapshot().remainingWaypoints).toHaveLength(0)
|
||||||
|
|
||||||
// 5. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw))
|
// 9. D2 1.13c Faster Run/Walk (FRW) diminishing returns: effectiveFRW = floor(150 * frw / (150 + frw))
|
||||||
// At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec
|
// At frw = 150%: effFrw = floor(22500 / 300) = 75% -> runSpeed = 13.5 + 9.0 * 0.75 = 20.25 sub-tiles/sec
|
||||||
predictor.setRunning(true)
|
predictor.setRunning(true)
|
||||||
predictor.setFasterRunWalkPercent(150)
|
predictor.setFasterRunWalkPercent(150)
|
||||||
|
|
@ -1650,8 +1656,8 @@ describe('Tier 5 Adversarial Coverage — F9–F13 ClientWorld, MapService, Path
|
||||||
predictor.setFasterRunWalkPercent(-200)
|
predictor.setFasterRunWalkPercent(-200)
|
||||||
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(1.5)
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(1.5)
|
||||||
|
|
||||||
// 6. 16-direction facing helper handles zero vector fallback cleanly
|
// 10. The dir64 routine keeps the current facing for a zero vector
|
||||||
expect(deltaToDir16(0, 0, 7)).toBe(7)
|
expect(dir64FromPoints(5000, 5000, 5000, 5000, 7)).toBe(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adv_19_remote_entity_interpolator_knockback_stop_and_remove', () => {
|
it('adv_19_remote_entity_interpolator_knockback_stop_and_remove', () => {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import { OutboundRateLimiter } from '../src/client/session/rate-limiter.ts'
|
||||||
import { ClientWorld } from '../src/client/world/client-world.ts'
|
import { ClientWorld } from '../src/client/world/client-world.ts'
|
||||||
import { getCanonicalItemDataTables } from '../src/client/world/item-tables-provider.ts'
|
import { getCanonicalItemDataTables } from '../src/client/world/item-tables-provider.ts'
|
||||||
import { COLLIDE_MASK_INVALID } from '../src/common/world/d2map.ts'
|
import { COLLIDE_MASK_INVALID } from '../src/common/world/d2map.ts'
|
||||||
|
import { isTownLevel } from '../src/common/world/scene-population.ts'
|
||||||
import {
|
import {
|
||||||
InMemoryPacketTap,
|
InMemoryPacketTap,
|
||||||
createD2OnlineFlow,
|
createD2OnlineFlow,
|
||||||
|
|
@ -380,61 +381,70 @@ function createSyntheticBotD2sBuffer(charName: string): Uint8Array {
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk-bot pass criterion, not a game rule: a non-teleport `0x15` that moves the bot further
|
||||||
|
* than this (sub-tiles) means its walk and the server disagreed.
|
||||||
|
*/
|
||||||
|
const BOT_MAX_REASSIGN_DRIFT = 5
|
||||||
|
|
||||||
async function runWalkSubcommand(
|
async function runWalkSubcommand(
|
||||||
world: ClientWorld,
|
world: ClientWorld,
|
||||||
adapter: GameServerAdapter,
|
adapter: GameServerAdapter,
|
||||||
mapService: MapService,
|
mapService: MapService,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const rateLimiter = new OutboundRateLimiter({ refillPerSec: 25, burstCapacity: 8 })
|
const rateLimiter = new OutboundRateLimiter({ refillPerSec: 25, burstCapacity: 8 })
|
||||||
|
const areaId = world.areaId || 1
|
||||||
|
const view =
|
||||||
|
mapService.getActiveLevelView() ?? mapService.getLevelView(areaId, 'all', true)
|
||||||
|
const inTown = isTownLevel(view.id)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[d2-bot:walk] Loaded LevelView area=${view.id} act=${view.act} seed=0x${view.seed.toString(16).padStart(8, '0')} origin=(${view.subOriginX},${view.subOriginY}) subSize=${view.subWidth}x${view.subHeight} walkableCells=${view.walkableCellCount} rooms=${view.rooms.length}`,
|
||||||
|
)
|
||||||
|
|
||||||
const predictor = new LocalMovementPredictor({
|
const predictor = new LocalMovementPredictor({
|
||||||
initialX: world.self.x,
|
initialX: world.self.x,
|
||||||
initialY: world.self.y,
|
initialY: world.self.y,
|
||||||
rubberBandThreshold: 5.0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let hardSnaps = 0
|
let hardSnaps = 0
|
||||||
let rubberBands = 0
|
let resyncRequests = 0
|
||||||
let serverCorrections = 0
|
let serverCorrections = 0
|
||||||
|
|
||||||
adapter.onEvent((ev) => {
|
adapter.onEvent((ev) => {
|
||||||
if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) {
|
if (ev.type === 'UnitReassign' && ev.unitType === 'player' && ev.id === world.selfId) {
|
||||||
serverCorrections++
|
serverCorrections++
|
||||||
const res = predictor.reconcileServerPosition({
|
const before = predictor.getSnapshot()
|
||||||
x: ev.at.x,
|
const drift = Math.hypot(ev.at.x - before.logicalX, ev.at.y - before.logicalY)
|
||||||
y: ev.at.y,
|
// 1.13c `0x15` repositions the player directly (D2Client 0x6FB5F640 → 0x6FB57E20).
|
||||||
teleport: ev.snap,
|
predictor.setPosition(ev.at.x, ev.at.y)
|
||||||
})
|
if (!ev.snap && drift > BOT_MAX_REASSIGN_DRIFT) {
|
||||||
if (!ev.snap && res.mode === 'hard-snap') {
|
|
||||||
hardSnaps++
|
hardSnaps++
|
||||||
console.warn(
|
console.warn(
|
||||||
`[d2-bot:walk] Unexpected UnitReassign hard-snap: drift=${res.driftDistance.toFixed(2)} to (${ev.at.x},${ev.at.y})`,
|
`[d2-bot:walk] Unexpected UnitReassign hard-snap: drift=${drift.toFixed(2)} to (${ev.at.x},${ev.at.y})`,
|
||||||
)
|
)
|
||||||
} else if (res.mode === 'rubber-band') {
|
|
||||||
rubberBands++
|
|
||||||
}
|
}
|
||||||
} else if (ev.type === 'LifeMana' && ev.at && ev.at.x > 0 && ev.at.y > 0) {
|
} else if (ev.type === 'LifeMana' && ev.at && ev.at.x > 0 && ev.at.y > 0) {
|
||||||
serverCorrections++
|
serverCorrections++
|
||||||
const res = predictor.reconcileServerPosition({
|
if (ev.vx === undefined || ev.vy === undefined) {
|
||||||
|
throw new Error('[d2-bot:walk] LifeMana has a position but no vx/vy motion vector')
|
||||||
|
}
|
||||||
|
// D2Client 0x6FADA1B0; the RTT is 0 until the first 0x8F pong, like D2Client's
|
||||||
|
// zero-initialised [0x6FBC9804].
|
||||||
|
const verdict = predictor.verifyServerPosition({
|
||||||
x: ev.at.x,
|
x: ev.at.x,
|
||||||
y: ev.at.y,
|
y: ev.at.y,
|
||||||
teleport: false,
|
source: { kind: 'life-mana', vx: ev.vx, vy: ev.vy },
|
||||||
|
rttMs: adapter.stats.rttMs ?? 0,
|
||||||
|
inTown,
|
||||||
})
|
})
|
||||||
if (res.mode === 'hard-snap') {
|
if (verdict.action === 'request-resync') {
|
||||||
predictor.setPosition(ev.at.x, ev.at.y)
|
resyncRequests++
|
||||||
} else if (res.mode === 'rubber-band') {
|
adapter.send({ type: 'UpdatePlayerPos', x: verdict.clientX, y: verdict.clientY })
|
||||||
rubberBands++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const areaId = world.areaId || 1
|
|
||||||
const view =
|
|
||||||
mapService.getActiveLevelView() ?? mapService.getLevelView(areaId, 'all', true)
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[d2-bot:walk] Loaded LevelView area=${view.id} act=${view.act} seed=0x${view.seed.toString(16).padStart(8, '0')} origin=(${view.subOriginX},${view.subOriginY}) subSize=${view.subWidth}x${view.subHeight} walkableCells=${view.walkableCellCount} rooms=${view.rooms.length}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface LandmarkTarget {
|
interface LandmarkTarget {
|
||||||
readonly label: string
|
readonly label: string
|
||||||
readonly x: number
|
readonly x: number
|
||||||
|
|
@ -560,12 +570,12 @@ async function runWalkSubcommand(
|
||||||
landmarksVisited++
|
landmarksVisited++
|
||||||
const snap = predictor.getSnapshot()
|
const snap = predictor.getSnapshot()
|
||||||
console.log(
|
console.log(
|
||||||
`[d2-bot:walk] Reached landmark ${landmarksVisited}/${selectedLandmarks.length} (${landmark.label}): predictor=(${snap.x.toFixed(1)},${snap.y.toFixed(1)}) worldSelf=(${world.self.x},${world.self.y}) hardSnaps=${hardSnaps} rubberBands=${rubberBands}`,
|
`[d2-bot:walk] Reached landmark ${landmarksVisited}/${selectedLandmarks.length} (${landmark.label}): predictor=(${snap.x.toFixed(1)},${snap.y.toFixed(1)}) worldSelf=(${world.self.x},${world.self.y}) hardSnaps=${hardSnaps} resyncRequests=${resyncRequests}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[d2-bot:walk:summary] landmarksVisited=${landmarksVisited} totalHops=${totalHops} subTilesWalked=${totalSubTilesWalked.toFixed(1)} serverCorrections=${serverCorrections} rubberBands=${rubberBands} hardSnaps=${hardSnaps}`,
|
`[d2-bot:walk:summary] landmarksVisited=${landmarksVisited} totalHops=${totalHops} subTilesWalked=${totalSubTilesWalked.toFixed(1)} serverCorrections=${serverCorrections} resyncRequests=${resyncRequests} hardSnaps=${hardSnaps}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (landmarksVisited < 3) {
|
if (landmarksVisited < 3) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue