diablo2-web/tests/client/remote-interpolation.test.ts

252 lines
7.9 KiB
TypeScript

/**
* Remote Entity Movement Interpolation & MonStats Velocity Tests (Issues #586, #598).
*
* Verifies Diablo II v1.13c ground truth:
* - Remote monster velocity derives from MonStats.txt (Velocity / Run) via computeMonsterMoveSpeedSubTilesPerSec
* - S2C 0x67 / 0x68 decodes nPathType and nVelocity
* - S2C nVelocity scales monster / unit speed, retaining the 25% speed floor
* - Units advance along waypoints cleanly without synthetic 1.5x catchup acceleration (#598)
* - Remote units settle cleanly on authoritative stop packets without sliding glide (#598)
*/
import { describe, expect, it } from 'vitest'
import {
RemoteEntityInterpolator,
resolveRemoteMoveSpeed,
} from '../../src/client/motion/interpolate.ts'
import {
D2_BASE_RUN_SUBTILES_PER_SEC,
D2_BASE_WALK_SUBTILES_PER_SEC,
} from '../../src/client/motion/predictor.ts'
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import {
decode0x67NpcMove,
decode0x68NpcMoveToTarget,
} from '../../src/netproto/d2gs/s2c/movement.ts'
describe('S2C Movement Packet Decoders for nPathType & nVelocity (Issue #586)', () => {
it('decodes nPathType and nVelocity from 0x67 NpcMove', () => {
// 0x67 packet:
// [0] 0x67
// [1..4] unitId u32LE (e.g. 101)
// [5] moveType u8 (e.g. 0x01 walk)
// [6..7] targetX u16LE (1234)
// [8..9] targetY u16LE (5678)
// [10..11] unk0x0A u16LE
// [12] nPathType u8 (e.g. 4)
// [13..14] nVelocity i16LE (e.g. 75 = 75% speed)
// [15] unk u8
const w = new ByteWriter(16)
w.u8(0x67)
w.u32LE(101)
w.u8(0x01) // walk
w.u16LE(1234)
w.u16LE(5678)
w.u16LE(0) // unk0x0A
w.u8(4) // nPathType
w.i16LE(75) // nVelocity = 75%
w.u8(0)
const event = decode0x67NpcMove(w.toUint8Array())
expect(event.type).toBe('UnitMove')
if (event.type === 'UnitMove') {
expect(event.id).toBe(101)
expect(event.mode).toBe('walk')
expect(event.to).toEqual({ x: 1234, y: 5678 })
expect(event.pathType).toBe(4)
expect(event.velocityPercent).toBe(75)
}
})
it('decodes nPathType and nVelocity from 0x68 NpcMoveToTarget', () => {
// 0x68 packet:
// [0] 0x68
// [1..4] unitId u32LE
// [5] moveType u8 (0x17 run)
// [6..7] currentX u16LE (100)
// [8..9] currentY u16LE (200)
// [10] targetType u8 (0 = player)
// [11..14] targetId u32LE (1)
// [15..16] unk0x0F u16LE
// [17] nPathType u8 (3)
// [18..19] nVelocity i16LE (120 = 120%)
// [20] unk u8
const w = new ByteWriter(21)
w.u8(0x68)
w.u32LE(202)
w.u8(0x17) // run
w.u16LE(100)
w.u16LE(200)
w.u8(0) // targetType = player
w.u32LE(1) // targetId
w.u16LE(0) // unk0x0F
w.u8(3) // nPathType
w.i16LE(120) // nVelocity
w.u8(0)
const event = decode0x68NpcMoveToTarget(w.toUint8Array())
expect(event.type).toBe('UnitMove')
if (event.type === 'UnitMove') {
expect(event.id).toBe(202)
expect(event.mode).toBe('run')
expect(event.pathType).toBe(3)
expect(event.velocityPercent).toBe(120)
expect(event.target).toEqual({ unitType: 0, id: 1 })
}
})
})
describe('resolveRemoteMoveSpeed with MonStats & velocityPercent (Issue #586)', () => {
it('resolves authentic monster speed using MonStats.txt data for NPCs and monsters', () => {
// Akara (classId 148): MonStats Velocity = 3 -> 1.171875 sub-tiles/sec
const akaraSpeed = resolveRemoteMoveSpeed({
unitType: 1,
classId: 148,
running: false,
})
expect(akaraSpeed.speedSubTilesPerSec).toBe(1.171875)
// Zombie (classId 19): MonStats Velocity = 15 -> 5.859375 sub-tiles/sec
const zombieSpeed = resolveRemoteMoveSpeed({
unitType: 1,
classId: 19,
running: false,
})
expect(zombieSpeed.speedSubTilesPerSec).toBe(5.859375)
// Fallen (classId 0): MonStats Velocity = 9 -> 3.515625 sub-tiles/sec
const fallenSpeed = resolveRemoteMoveSpeed({
unitType: 1,
classId: 0,
running: false,
})
expect(fallenSpeed.speedSubTilesPerSec).toBe(3.515625)
})
it('scales monster speed with nVelocity while maintaining 25% floor', () => {
// Zombie at 75% speed
const chilledZombie = resolveRemoteMoveSpeed({
unitType: 1,
classId: 19,
running: false,
velocityPercent: 75,
})
expect(chilledZombie.speedSubTilesPerSec).toBe(5.859375)
// Severe slow (10% velocityPercent) clamped to 25% floor:
const frozenZombie = resolveRemoteMoveSpeed({
unitType: 1,
classId: 19,
running: false,
velocityPercent: 10,
})
// 25% floor: computeMonsterMoveSpeedSubTilesPerSec(19, false, 25)
expect(frozenZombie.speedSubTilesPerSec).toBe(
computeMonsterMoveSpeedSubTilesPerSec(19, false, 25),
)
})
it('scales remote player speed with nVelocity while maintaining 25% floor', () => {
// Remote player walking at 50% speed
const chilledPlayer = resolveRemoteMoveSpeed({
unitType: 0,
running: false,
velocityPercent: 50,
})
expect(chilledPlayer.speedSubTilesPerSec).toBe(D2_BASE_WALK_SUBTILES_PER_SEC * 0.5)
// Remote player at 10% speed clamped to 25% floor
const frozenPlayer = resolveRemoteMoveSpeed({
unitType: 0,
running: false,
velocityPercent: 10,
})
expect(frozenPlayer.speedSubTilesPerSec).toBe(D2_BASE_WALK_SUBTILES_PER_SEC * 0.25)
})
})
describe('RemoteEntityInterpolator Kinematics & Parity (Issue #598)', () => {
it('advances remote monsters at authentic velocity without 1.5x synthetic catchup acceleration', () => {
const interpolator = new RemoteEntityInterpolator()
// Spawn Akara at visual position (98, 100) lagging behind server
interpolator.spawnOrSnap({
unitType: 1,
unitId: 10,
classId: 148,
x: 98,
y: 100,
})
// Server sends move to (105, 100) with server position at (100, 100)
// Server segment distance is 5, but visual distance is 7
const moveCmd = interpolator.startMove({
unitType: 1,
unitId: 10,
currentX: 100,
currentY: 100,
targetX: 105,
targetY: 100,
running: false,
})
// In 1.13c parity (Issue #598): speed is strictly authentic MonStats speed (1.171875),
// NOT boosted by arbitrary 1.5x catchup multiplier
expect(moveCmd.speedSubTilesPerSec).toBe(1.171875)
// Step 1 second (1000ms)
interpolator.update(1000)
const state = interpolator.getEntityState(1, 10)!
expect(state.moving).toBe(true)
// Advance = 1.171875 sub-tiles from currentX (98)
expect(state.x).toBeCloseTo(98 + 1.171875, 4)
})
it('settles cleanly on authoritative stop packets without post-stop sliding deceleration', () => {
const interpolator = new RemoteEntityInterpolator()
interpolator.spawnOrSnap({
unitType: 1,
unitId: 20,
classId: 19, // Zombie
x: 100,
y: 100,
})
interpolator.startMove({
unitType: 1,
unitId: 20,
targetX: 110,
targetY: 100,
running: false,
})
// Update 500ms -> unit moves toward target
interpolator.update(500)
const movingState = interpolator.getEntityState(1, 20)!
expect(movingState.moving).toBe(true)
// Server stop packet received at movingState.x
const stoppedState = interpolator.stopEntity({
unitType: 1,
unitId: 20,
x: movingState.x,
y: 100,
})
// In 1.13c parity (Issue #598): unit immediately settles at authoritative stop position,
// moving is set to false, and no post-stop overshoot happens
expect(stoppedState.moving).toBe(false)
expect(stoppedState.x).toBe(movingState.x)
expect(stoppedState.y).toBe(100)
// Further updates do not move the unit
interpolator.update(500)
const afterUpdateState = interpolator.getEntityState(1, 20)!
expect(afterUpdateState.moving).toBe(false)
expect(afterUpdateState.x).toBe(movingState.x)
expect(afterUpdateState.y).toBe(100)
})
})