425 lines
20 KiB
TypeScript
425 lines
20 KiB
TypeScript
/**
|
||
* 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 { OnlineSession } from '../../src/client/session/online-session.ts'
|
||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts'
|
||
import { MONSTER_HCIDX_VELOCITIES } from '../../src/common/units/monster-mapping.ts'
|
||
import {
|
||
computeMonsterMoveSpeedSubTilesPerSec,
|
||
getMonsterKindByClassId,
|
||
} from '../../src/common/units/monsters-data.ts'
|
||
import { ByteWriter, decodeD2gsServerPacket } from '../../src/netproto/index.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 })
|
||
})
|
||
})
|
||
|
||
describe('skill-cast facing (ClientWorld)', () => {
|
||
it('turns the caster toward the target as a dir64 and keeps it for a target underfoot', () => {
|
||
const world = new ClientWorld()
|
||
world.apply({ type: 'UnitAssign', unit: { unitType: 1, id: 40, classId: 5, x: 5000, y: 5000 } })
|
||
const castAt = (x: number, y: number): number => {
|
||
world.apply({ type: 'SkillCast', caster: { unitType: 1, id: 40 }, skill: 0, level: 1, target: { x, y } })
|
||
return world.getUnit('monster', 40)!.direction
|
||
}
|
||
expect(castAt(5010, 5000)).toBe(56) // world +X: screen south-east
|
||
expect(castAt(5000, 5000)).toBe(56)
|
||
expect(castAt(4990, 4990)).toBe(32) // world −X−Y: screen north
|
||
})
|
||
})
|
||
|
||
describe('1.13c monster movement velocity & anti-rubber-banding (MonStats.txt + D2Common 0x6FD5D240)', () => {
|
||
it('populates all 734 MonStats.txt [Velocity, Run] pairs and computes exact 1.13c sub-tile/sec speeds', () => {
|
||
expect(MONSTER_HCIDX_VELOCITIES.length).toBe(734)
|
||
|
||
// Zombies (zombie1..zombie5, hcIdx = 5..9): Velocity = 1 in MonStats.txt ([1,3], [1,4], [1,4], [1,5], [1,5])
|
||
const expectedZombieRun = [3, 4, 4, 5, 5] as const
|
||
for (let hcIdx = 5; hcIdx <= 9; hcIdx++) {
|
||
expect(MONSTER_HCIDX_VELOCITIES[hcIdx]).toEqual([1, expectedZombieRun[hcIdx - 5]])
|
||
const kind = getMonsterKindByClassId(hcIdx)
|
||
expect(kind).toBeDefined()
|
||
expect(kind?.velocity).toBe(1)
|
||
expect(kind?.runVelocity).toBe(expectedZombieRun[hcIdx - 5])
|
||
// dwVelocity = floor((75 * 256) / 100) = 192 => 192 * 25 / 4096 = 1.171875 sub-tiles/s
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false, 75)).toBe(1.171875)
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false)).toBe(1.171875)
|
||
}
|
||
|
||
// Skeleton (skeleton1, hcIdx = 0): Velocity = 3, Run = 3
|
||
// dwVelocity = floor((75 * 768) / 100) = 576 => 576 * 25 / 4096 = 3.515625 sub-tiles/s
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(0, false, 75)).toBe(3.515625)
|
||
|
||
// Fallen (fallen1, hcIdx = 19): Velocity = 5, Run = 5
|
||
// Walk (nVelocity = 75): dwVelocity = floor((75 * 1280) / 100) = 960 => 960 * 25 / 4096 = 5.859375 sub-tiles/s
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(19, false, 75)).toBe(5.859375)
|
||
// With explicit run velocityPercent = 155: dwVelocity = floor((155 * 1280) / 100) = 1984 => 12.109375 sub-tiles/s
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(19, true, 155)).toBe(12.109375)
|
||
|
||
// Corrupt Rogue (corruptrogue2, hcIdx = 44): Velocity = 6, Run = 8
|
||
// Fallback run without velocityPercent: runBonus = floor(800/6) - 100 = 33 => rawPercent = 108 => dwVelocity = floor((108 * 1536) / 100) = 1658 => 1658 * 25 / 4096 = 10.11962890625
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(44, true)).toBeCloseTo((25 * 1658) / 4096, 8)
|
||
|
||
// Akara (hcIdx = 148, Velocity = 1) and Kashya (hcIdx = 150, Velocity = 3) from 04-d2gs-idle-60s.d2cap
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(148, false, 75)).toBe(1.171875)
|
||
expect(computeMonsterMoveSpeedSubTilesPerSec(150, false, 75)).toBe(3.515625)
|
||
})
|
||
|
||
it('interpolates Zombie (hcIdx=5) 0x67 NPCMove and 0x68 NPCMoveToTarget at 1.171875 sub-tiles/s without rubber-banding', () => {
|
||
const session = new OnlineSession()
|
||
|
||
// 1. Spawn local player at (5010, 5000) and Zombie (classId=5) at (5000, 5000)
|
||
session.handleServerEvent({
|
||
type: 'UnitAssign',
|
||
isSelf: true,
|
||
unit: { unitType: 'player', id: 1, classId: 1, name: 'Hero', x: 5010, y: 5000, mode: 1, lifePct: 128 },
|
||
})
|
||
session.handleServerEvent({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 'monster', id: 200, classId: 5, x: 5000, y: 5000, mode: 1, lifePct: 128 },
|
||
})
|
||
|
||
// 2. Server sends 0x67 NPCMove for Zombie to walk 3 sub-tiles to (5003, 5000) with nVelocity = 75 (takes 64 ticks = 2560ms)
|
||
const w67 = new ByteWriter(16)
|
||
w67.u8(0x67)
|
||
w67.u32LE(200)
|
||
w67.u8(0x01) // walk
|
||
w67.u16LE(5003)
|
||
w67.u16LE(5000)
|
||
w67.u16LE(0)
|
||
w67.u8(2)
|
||
w67.i16LE(75)
|
||
w67.u8(0)
|
||
session.handleServerEvent(decodeD2gsServerPacket(w67.toUint8Array()))
|
||
|
||
// Immediately after packet ingestion, Zombie must still be at (5000, 5000), NOT teleported to 5003
|
||
expect(session.world.getUnit('monster', 200)?.x).toBe(5000)
|
||
expect(session.interpolator.getEntityState(1, 200)?.x).toBe(5000)
|
||
|
||
// 3. Advance 32 ticks (1280ms = half of 2560ms): Zombie must be at 5001.5 (NOT prematurely snapped to 5003 as with old 9 sub-tiles/s!)
|
||
for (let i = 0; i < 32; i++) {
|
||
session.tick(40, 1000 + (i + 1) * 40)
|
||
}
|
||
expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5001.5, 5)
|
||
expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5001.5, 5)
|
||
expect(session.world.getUnit('monster', 200)?.mode).toBe(2) // still walking (WL)
|
||
|
||
// 4. Advance remaining 32 ticks (total 2560ms): Zombie reaches (5003, 5000) exactly when server 0x6D NPCStop arrives
|
||
for (let i = 32; i < 64; i++) {
|
||
session.tick(40, 1000 + (i + 1) * 40)
|
||
}
|
||
expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5003, 5)
|
||
|
||
// Server sends 0x6D NPCStop at (5003, 5000) -> zero rubber-band jump!
|
||
session.handleServerEvent({
|
||
type: 'UnitMove',
|
||
unitType: 'monster',
|
||
id: 200,
|
||
mode: 'stop',
|
||
to: { x: 5003, y: 5000 },
|
||
})
|
||
expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5003, 5)
|
||
|
||
// 5. Server sends 0x68 NPCMoveToTarget chasing local player (id=1 at 5010, 5000) from current (5003, 5000) with nVelocity=75
|
||
const w68 = new ByteWriter(21)
|
||
w68.u8(0x68)
|
||
w68.u32LE(200)
|
||
w68.u8(0x00) // walk in 0x68
|
||
w68.u16LE(5003)
|
||
w68.u16LE(5000)
|
||
w68.u8(0) // targetType = player
|
||
w68.u32LE(1) // targetId = 1
|
||
w68.u16LE(0)
|
||
w68.u8(2)
|
||
w68.i16LE(75)
|
||
w68.u8(0)
|
||
session.handleServerEvent(decodeD2gsServerPacket(w68.toUint8Array()))
|
||
|
||
// Step 25 ticks (1000ms): Zombie advances monotonically by 1.171875 sub-tiles to 5004.171875
|
||
let prevX = session.world.getUnit('monster', 200)!.x
|
||
for (let i = 0; i < 25; i++) {
|
||
session.tick(40, 4000 + (i + 1) * 40)
|
||
const curX = session.world.getUnit('monster', 200)!.x
|
||
expect(curX).toBeGreaterThanOrEqual(prevX)
|
||
prevX = curX
|
||
}
|
||
expect(prevX).toBeCloseTo(5003 + 1.171875, 4)
|
||
|
||
// Server sends next 0x68 re-sync after ~21 ticks (from X = 5004, Y = 5000): Zombie must NOT jump backward or teleport!
|
||
const w68Next = new ByteWriter(21)
|
||
w68Next.u8(0x68)
|
||
w68Next.u32LE(200)
|
||
w68Next.u8(0x00)
|
||
w68Next.u16LE(5004)
|
||
w68Next.u16LE(5000)
|
||
w68Next.u8(0)
|
||
w68Next.u32LE(1)
|
||
w68Next.u16LE(0)
|
||
w68Next.u8(2)
|
||
w68Next.i16LE(75)
|
||
w68Next.u8(0)
|
||
const xBeforeResync = session.world.getUnit('monster', 200)!.x
|
||
session.handleServerEvent(decodeD2gsServerPacket(w68Next.toUint8Array()))
|
||
const xAfterResync = session.world.getUnit('monster', 200)!.x
|
||
// Drift is ~0.17 sub-tiles (< 1.5 soft-reconcile threshold), so visual position stays at xBeforeResync without rubber-banding!
|
||
expect(xAfterResync).toBeCloseTo(xBeforeResync, 5)
|
||
|
||
session.destroy()
|
||
})
|
||
})
|
||
|