feat(net): Phase B.2 — MoveToState + AutonomousPosition message builders
Outbound GameAction message builders for player movement: - MoveToState (0xF61C): sent on motion state changes (start/stop walking, turn, speed change). Carries RawMotionState (flag-driven variable fields) + WorldPosition + sequence numbers. - AutonomousPosition (0xF753): periodic position heartbeat sent every ~200ms while moving. No RawMotionState — just WorldPosition + sequences + contact byte. Both follow the GameAction envelope pattern (0xF7B1 + sequence + action type) established by GameActionLoginComplete. Wire format ported from references/holtburger movement protocol — field order and alignment match exactly (contact byte + pad_to_4). Also: - Adds WriteFloat to PacketWriter (needed by both builders) - Adds SendGameAction + NextGameActionSequence to WorldSession (public wrappers for PlayerMovementController in Task 2) 11 new tests, 265 total, all green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d9cd2b0b1d
commit
fe1c949775
6 changed files with 577 additions and 0 deletions
131
tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
Normal file
131
tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public class AutonomousPositionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ProducesValidGameAction()
|
||||
{
|
||||
var body = AutonomousPosition.Build(
|
||||
gameActionSequence: 5,
|
||||
cellId: 0xA9B40001u,
|
||||
position: new Vector3(100f, 100f, 50f),
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
|
||||
Assert.Equal(0xF7B1u, opcode);
|
||||
|
||||
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
|
||||
Assert.Equal(5u, seq);
|
||||
|
||||
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
|
||||
Assert.Equal(0xF753u, actionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ContainsCellIdAfterHeader()
|
||||
{
|
||||
var body = AutonomousPosition.Build(
|
||||
gameActionSequence: 1,
|
||||
cellId: 0xDEADBEEFu,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
// After the 12-byte GameAction header, the WorldPosition starts
|
||||
// with u32 cell_id.
|
||||
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
|
||||
Assert.Equal(0xDEADBEEFu, cellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ContainsPosition_AfterCellId()
|
||||
{
|
||||
var body = AutonomousPosition.Build(
|
||||
gameActionSequence: 2,
|
||||
cellId: 0xA9B40001u,
|
||||
position: new Vector3(12.5f, 34.0f, 56.75f),
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
// WorldPosition: cellId (4) + x (4) + y (4) + z (4) at offsets 12-27
|
||||
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16));
|
||||
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20));
|
||||
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
|
||||
Assert.Equal(12.5f, x);
|
||||
Assert.Equal(34.0f, y);
|
||||
Assert.Equal(56.75f, z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsAlignedTo4Bytes()
|
||||
{
|
||||
var body = AutonomousPosition.Build(
|
||||
gameActionSequence: 3,
|
||||
cellId: 0xA9B40001u,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
Assert.Equal(0, body.Length % 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TotalLengthIsCorrect_NoCommandsNoExtraFields()
|
||||
{
|
||||
// 12 (envelope) + 32 (WorldPosition) + 8 (4x u16 sequences) + 1 (contact) + 3 (align) = 56
|
||||
var body = AutonomousPosition.Build(
|
||||
gameActionSequence: 4,
|
||||
cellId: 0xA9B40001u,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
Assert.Equal(56, body.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ContainsIdentityRotation_AfterPosition()
|
||||
{
|
||||
var body = AutonomousPosition.Build(
|
||||
gameActionSequence: 6,
|
||||
cellId: 0xA9B40001u,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity, // W=1, X=Y=Z=0
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
// Rotation starts at offset 28: rotW(4), rotX(4), rotY(4), rotZ(4)
|
||||
float rotW = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(28));
|
||||
float rotX = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(32));
|
||||
float rotY = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(36));
|
||||
float rotZ = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(40));
|
||||
Assert.Equal(1.0f, rotW);
|
||||
Assert.Equal(0.0f, rotX);
|
||||
Assert.Equal(0.0f, rotY);
|
||||
Assert.Equal(0.0f, rotZ);
|
||||
}
|
||||
}
|
||||
172
tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
Normal file
172
tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public class MoveToStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_IdleState_ProducesValidGameAction()
|
||||
{
|
||||
var body = MoveToState.Build(
|
||||
gameActionSequence: 1,
|
||||
forwardCommand: null,
|
||||
forwardSpeed: null,
|
||||
sidestepCommand: null,
|
||||
sidestepSpeed: null,
|
||||
turnCommand: null,
|
||||
turnSpeed: null,
|
||||
holdKey: null,
|
||||
cellId: 0xA9B40001u,
|
||||
position: new Vector3(96f, 96f, 50f),
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
// First 4 bytes: GameAction opcode 0xF7B1
|
||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
|
||||
Assert.Equal(0xF7B1u, opcode);
|
||||
|
||||
// Bytes 4-7: game action sequence
|
||||
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
|
||||
Assert.Equal(1u, seq);
|
||||
|
||||
// Bytes 8-11: MoveToState action type 0xF61C
|
||||
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
|
||||
Assert.Equal(0xF61Cu, actionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WalkForward_IncludesForwardCommandInFlags()
|
||||
{
|
||||
var body = MoveToState.Build(
|
||||
gameActionSequence: 2,
|
||||
forwardCommand: 0x45000005u, // WalkForward
|
||||
forwardSpeed: 1.0f,
|
||||
sidestepCommand: null,
|
||||
sidestepSpeed: null,
|
||||
turnCommand: null,
|
||||
turnSpeed: null,
|
||||
holdKey: null,
|
||||
cellId: 0xA9B40001u,
|
||||
position: new Vector3(96f, 96f, 50f),
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
// After the 12-byte GameAction header comes RawMotionState.
|
||||
// First u32 is the packed flags word. ForwardCommand flag = 0x4.
|
||||
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
|
||||
Assert.True((flags & 0x4u) != 0, "ForwardCommand flag (0x4) should be set");
|
||||
// ForwardSpeed flag = 0x10
|
||||
Assert.True((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) should be set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IdleState_RawMotionFlagsAreZero()
|
||||
{
|
||||
var body = MoveToState.Build(
|
||||
gameActionSequence: 3,
|
||||
forwardCommand: null,
|
||||
forwardSpeed: null,
|
||||
sidestepCommand: null,
|
||||
sidestepSpeed: null,
|
||||
turnCommand: null,
|
||||
turnSpeed: null,
|
||||
holdKey: null,
|
||||
cellId: 0xA9B40001u,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
|
||||
Assert.Equal(0u, flags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IdleState_WorldPositionFollowsMotionState()
|
||||
{
|
||||
// With no motion state, flags = 0 and no conditional fields are written.
|
||||
// So WorldPosition starts at offset 12 (envelope) + 4 (flags) = 16.
|
||||
var body = MoveToState.Build(
|
||||
gameActionSequence: 4,
|
||||
forwardCommand: null,
|
||||
forwardSpeed: null,
|
||||
sidestepCommand: null,
|
||||
sidestepSpeed: null,
|
||||
turnCommand: null,
|
||||
turnSpeed: null,
|
||||
holdKey: null,
|
||||
cellId: 0xDEADBEEFu,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
|
||||
Assert.Equal(0xDEADBEEFu, cellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsAlignedTo4Bytes()
|
||||
{
|
||||
var body = MoveToState.Build(
|
||||
gameActionSequence: 5,
|
||||
forwardCommand: null,
|
||||
forwardSpeed: null,
|
||||
sidestepCommand: null,
|
||||
sidestepSpeed: null,
|
||||
turnCommand: null,
|
||||
turnSpeed: null,
|
||||
holdKey: null,
|
||||
cellId: 0xA9B40001u,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
Assert.Equal(0, body.Length % 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithHoldKey_IncludesHoldKeyFlag()
|
||||
{
|
||||
var body = MoveToState.Build(
|
||||
gameActionSequence: 6,
|
||||
forwardCommand: null,
|
||||
forwardSpeed: null,
|
||||
sidestepCommand: null,
|
||||
sidestepSpeed: null,
|
||||
turnCommand: null,
|
||||
turnSpeed: null,
|
||||
holdKey: 2u, // Run
|
||||
cellId: 0xA9B40001u,
|
||||
position: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
instanceSequence: 0,
|
||||
serverControlSequence: 0,
|
||||
teleportSequence: 0,
|
||||
forcePositionSequence: 0);
|
||||
|
||||
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
|
||||
Assert.True((flags & 0x1u) != 0, "CurrentHoldKey flag (0x1) should be set");
|
||||
|
||||
// The hold key value (u32 = 2) should immediately follow the flags
|
||||
uint holdKeyValue = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
|
||||
Assert.Equal(2u, holdKeyValue);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue