diablo2-web/tests/client/motion-kinematics-batch5.te...

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 })
})
})
})