233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
/**
|
|
* Batch 5 Phase 2 Comprehensive Integration Test Suite (Issues #586, #587, #588, #598, #600).
|
|
*
|
|
* Validates complete 1.13c parity for:
|
|
* 1. Remote Entity Movement Velocity & S2C 0x67/0x68 nVelocity / nPathType (#586)
|
|
* 2. Local Player Predicted Velocity with FRW Kinematics, Stamina, and Chill (#587)
|
|
* 3. Authentic PATHTYPE_STRAIGHT Local Pathing (#588)
|
|
* 4. Remote Unit Movement Interpolation Alignment without Artificial Accelerations (#598)
|
|
* 5. Position Gate gateModeBase Aligned with Authentic PlrMode Values (#600)
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
RemoteEntityInterpolator,
|
|
resolveRemoteMoveSpeed,
|
|
} from '../../src/client/motion/interpolate.ts'
|
|
import {
|
|
findPathLocal,
|
|
hasLineOfSightLocal,
|
|
MAX_ASTAR_OBSTACLE_DISTANCE_SQ,
|
|
MAX_STRAIGHT_DISTANCE_DELTA,
|
|
MAXPATHLEN,
|
|
type SubTileWalkabilityGrid,
|
|
} from '../../src/client/motion/pathfind.ts'
|
|
import {
|
|
D2_BASE_RUN_SUBTILES_PER_SEC,
|
|
D2_BASE_WALK_SUBTILES_PER_SEC,
|
|
LocalMovementPredictor,
|
|
} from '../../src/client/motion/predictor.ts'
|
|
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts'
|
|
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
|
|
import {
|
|
decode0x67NpcMove,
|
|
decode0x68NpcMoveToTarget,
|
|
} from '../../src/netproto/d2gs/s2c/movement.ts'
|
|
|
|
function createGrid(
|
|
width: number,
|
|
height: number,
|
|
isBlocked: (x: number, y: number) => boolean = () => false,
|
|
): SubTileWalkabilityGrid {
|
|
const walkable = new Uint8Array(width * height)
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
walkable[y * width + x] = isBlocked(x, y) ? 0 : 1
|
|
}
|
|
}
|
|
return { subWidth: width, subHeight: height, walkable }
|
|
}
|
|
|
|
describe('Batch 5 Phase 2: Motion Kinematics, Velocity & Pathing Integration', () => {
|
|
describe('Issue #586 & #598: Remote entity kinematics & MonStats', () => {
|
|
it('processes S2C 0x67 NPCMove packet and interpolates monster at exact MonStats speed', () => {
|
|
// Decode S2C 0x67 for Zombie (classId 19, unitId 50) moving to (100, 100) with nVelocity = 100
|
|
const bw = new ByteWriter(16)
|
|
bw.u8(0x67)
|
|
bw.u32LE(50) // unitId
|
|
bw.u8(0x01) // walk
|
|
bw.u16LE(100) // targetX
|
|
bw.u16LE(100) // targetY
|
|
bw.u16LE(0) // unk
|
|
bw.u8(4) // nPathType
|
|
bw.i16LE(100) // nVelocity
|
|
bw.u8(0)
|
|
|
|
const ev = decode0x67NpcMove(bw.toUint8Array())
|
|
expect(ev.type).toBe('UnitMove')
|
|
if (ev.type !== 'UnitMove') return
|
|
expect(ev.pathType).toBe(4)
|
|
expect(ev.velocityPercent).toBe(100)
|
|
|
|
const interpolator = new RemoteEntityInterpolator()
|
|
interpolator.spawnOrSnap({
|
|
unitType: 1,
|
|
unitId: 50,
|
|
classId: 19,
|
|
x: 90,
|
|
y: 100,
|
|
})
|
|
|
|
const moveState = interpolator.startMove({
|
|
unitType: 1,
|
|
unitId: 50,
|
|
classId: 19,
|
|
targetX: ev.to.x,
|
|
targetY: ev.to.y,
|
|
velocityPercent: ev.velocityPercent,
|
|
pathType: ev.pathType,
|
|
})
|
|
|
|
const expectedSpeed = computeMonsterMoveSpeedSubTilesPerSec(19, false, 100)
|
|
expect(moveState.speedSubTilesPerSec).toBe(expectedSpeed)
|
|
|
|
// Step 500ms
|
|
interpolator.update(500)
|
|
const afterStep = interpolator.getEntityState(1, 50)!
|
|
expect(afterStep.moving).toBe(true)
|
|
expect(afterStep.x).toBeCloseTo(90 + (expectedSpeed * 500) / 1000, 4)
|
|
|
|
// Server stop packet settles unit cleanly at authoritative stop position (#598)
|
|
const stopped = interpolator.stopEntity({
|
|
unitType: 1,
|
|
unitId: 50,
|
|
x: afterStep.x,
|
|
y: 100,
|
|
})
|
|
expect(stopped.moving).toBe(false)
|
|
expect(stopped.x).toBe(afterStep.x)
|
|
expect(stopped.y).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('Issue #587 & #600: Player FRW kinematics & gateModeBase', () => {
|
|
it('simulates player running with FRW boots, Vigor aura, medium armor, and stamina exhaustion', () => {
|
|
const predictor = new LocalMovementPredictor({ initialX: 1000, initialY: 1000 })
|
|
predictor.setRunning(true)
|
|
|
|
// Equip 40% FRW boots (Item FRW -> floor(150*40/190) = floor(6000/190) = 31%)
|
|
// Vigor level 1 aura (+32% skill FRW)
|
|
// Medium armor (-5% speed penalty)
|
|
// Chilled by cold damage (-30% slow)
|
|
predictor.setVelocityModifiers({
|
|
fasterRunWalkPercent: 40,
|
|
skillVelocityPercent: 32,
|
|
armorSpeedPenaltyPercent: 5,
|
|
slowPercent: 30,
|
|
staminaExhausted: false,
|
|
})
|
|
|
|
// Running total = 100 + 31 (item) + 50 (run) + 32 (skill) - 5 (armor) - 30 (chill) = 178%
|
|
// Speed = 9.0 * 1.78 = 16.02
|
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(16.02, 5)
|
|
|
|
// Stamina exhausted -> running bonus (50%) removed
|
|
predictor.setStaminaExhausted(true)
|
|
// Walking total = 100 + 31 + 32 - 5 - 30 = 128%
|
|
// Speed = 9.0 * 1.28 = 11.52
|
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBeCloseTo(11.52, 5)
|
|
|
|
// Severe slow tests 25% minimum speed floor: 9.0 * 0.25 = 2.25
|
|
predictor.setSlowPercent(150)
|
|
expect(predictor.getEffectiveSpeedSubTilesPerSec()).toBe(2.25)
|
|
})
|
|
|
|
it('verifies position gate tolerance with authentic PlrMode mapping (#600)', () => {
|
|
const predictor = new LocalMovementPredictor({ initialX: 2000, initialY: 2000 })
|
|
|
|
// PlrMode 1 (NU - standing outside town): base tolerance is 3
|
|
const checkNu = predictor.verifyServerPosition({
|
|
x: 2003,
|
|
y: 2000,
|
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
|
rttMs: 0,
|
|
inTown: false,
|
|
plrMode: 1,
|
|
})
|
|
expect(checkNu.threshold).toBe(3)
|
|
expect(checkNu.action).toBe('none')
|
|
|
|
// 4 sub-tiles drift exceeds tolerance 3
|
|
const checkNuExceeded = predictor.verifyServerPosition({
|
|
x: 2004,
|
|
y: 2000,
|
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
|
rttMs: 0,
|
|
inTown: false,
|
|
plrMode: 1,
|
|
})
|
|
expect(checkNuExceeded.action).toBe('request-resync')
|
|
|
|
// PlrMode 3 (RN - running): base tolerance is 7
|
|
const checkRn = predictor.verifyServerPosition({
|
|
x: 2007,
|
|
y: 2000,
|
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
|
rttMs: 0,
|
|
inTown: false,
|
|
plrMode: 3,
|
|
})
|
|
expect(checkRn.threshold).toBe(7)
|
|
expect(checkRn.action).toBe('none')
|
|
|
|
// PlrMode 8 (A2 - attacking) / other modes: base tolerance is 5
|
|
const checkCast = predictor.verifyServerPosition({
|
|
x: 2005,
|
|
y: 2000,
|
|
source: { kind: 'life-mana', vx: 0, vy: 0 },
|
|
rttMs: 0,
|
|
inTown: false,
|
|
plrMode: 8,
|
|
})
|
|
expect(checkCast.threshold).toBe(5)
|
|
expect(checkCast.action).toBe('none')
|
|
})
|
|
})
|
|
|
|
describe('Issue #588: Authentic PATHTYPE_STRAIGHT and predictor execution', () => {
|
|
it('generates straight line path and steps predictor along it', () => {
|
|
const grid = createGrid(50, 50)
|
|
const predictor = new LocalMovementPredictor({ initialX: 10, initialY: 10 })
|
|
predictor.setRunning(false) // 9.0 sub-tiles/sec
|
|
|
|
// Path from (10, 10) to (19, 10) -> distance 9 sub-tiles
|
|
const pathResult = findPathLocal(grid, 10, 10, 19, 10)
|
|
expect(pathResult.reachedGoal).toBe(true)
|
|
|
|
predictor.setPath(pathResult.path, false)
|
|
expect(predictor.getSnapshot().moving).toBe(true)
|
|
|
|
// At 9.0 sub-tiles/sec, stepping 1 second (1000ms = 25 ticks) completes the 9-subtile segment
|
|
predictor.stepTicks(25)
|
|
const snap = predictor.getSnapshot()
|
|
expect(snap.logicalX).toBeCloseTo(19, 4)
|
|
expect(snap.logicalY).toBeCloseTo(10, 4)
|
|
expect(snap.moving).toBe(false)
|
|
})
|
|
|
|
it('bypasses local obstacles within 18 subtiles and stops at far obstacles', () => {
|
|
// 1. Within 18 subtiles: start (0, 5), goal (10, 5), obstacle at x=5
|
|
const gridNear = createGrid(25, 25, (x, y) => x === 5 && y >= 4 && y <= 6)
|
|
const resNear = findPathLocal(gridNear, 0, 5, 10, 5)
|
|
expect(resNear.reachedGoal).toBe(true)
|
|
|
|
// 2. Beyond 18 subtiles: start (0, 10), goal (25, 10), obstacle at x=15
|
|
// distSq = 25^2 = 625 > 324
|
|
const gridFar = createGrid(40, 40, (x, y) => x === 15 && y >= 0 && y <= 20)
|
|
const resFar = findPathLocal(gridFar, 0, 10, 25, 10)
|
|
expect(resFar.reachedGoal).toBe(false)
|
|
expect(resFar.path[resFar.path.length - 1]).toEqual({ x: 14, y: 10 })
|
|
})
|
|
})
|
|
})
|