refactor(world): extract live entity network updates
Move Position, Vector, State, Movement, and equal-generation CreateObject routing out of GameWindow while preserving per-channel authority, ForcePosition acknowledgement, and motion-runtime ownership. Add adversarial authority and exact-wire coverage so reentrant updates and GUID reuse cannot publish stale state.
This commit is contained in:
parent
c203e4799a
commit
aa90c64666
19 changed files with 3701 additions and 2241 deletions
|
|
@ -0,0 +1,114 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class LocalPlayerImmediatePositionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForcePositionAcknowledgement_SendsOneExactAutonomousPositionAndStampsIt()
|
||||
{
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000));
|
||||
session.PublishAcceptedLocalPhysicsTimestamps(
|
||||
instance: 11,
|
||||
serverControlledMove: 12,
|
||||
teleport: 13,
|
||||
forcePosition: 14);
|
||||
var sent = new List<byte[]>();
|
||||
session.GameActionCapture = sent.Add;
|
||||
|
||||
PlayerMovementController player = GroundedPlayer();
|
||||
Position preTurnCanonical = player.CellPosition;
|
||||
Quaternion rotation = Quaternion.Normalize(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f));
|
||||
player.SetBodyOrientation(rotation);
|
||||
Assert.NotEqual(rotation, preTurnCanonical.Frame.Orientation);
|
||||
Assert.True(player.TryGetOutboundPosition(out Position outboundPosition));
|
||||
var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
|
||||
|
||||
outbound.SendImmediatePosition(session, player);
|
||||
|
||||
byte[] body = Assert.Single(sent);
|
||||
Assert.Equal(56, body.Length);
|
||||
Assert.Equal(AutonomousPosition.GameActionOpcode, U32(body, 0));
|
||||
Assert.Equal(1u, U32(body, 4));
|
||||
Assert.Equal(AutonomousPosition.AutonomousPositionAction, U32(body, 8));
|
||||
Assert.Equal(outboundPosition.ObjCellId, U32(body, 12));
|
||||
Assert.Equal(outboundPosition.Frame.Origin.X, F32(body, 16));
|
||||
Assert.Equal(outboundPosition.Frame.Origin.Y, F32(body, 20));
|
||||
Assert.Equal(outboundPosition.Frame.Origin.Z, F32(body, 24));
|
||||
Assert.Equal(rotation.W, F32(body, 28), precision: 6);
|
||||
Assert.Equal(rotation.X, F32(body, 32), precision: 6);
|
||||
Assert.Equal(rotation.Y, F32(body, 36), precision: 6);
|
||||
Assert.Equal(rotation.Z, F32(body, 40), precision: 6);
|
||||
Assert.Equal((ushort)11, U16(body, 44));
|
||||
Assert.Equal((ushort)12, U16(body, 46));
|
||||
Assert.Equal((ushort)13, U16(body, 48));
|
||||
Assert.Equal((ushort)14, U16(body, 50));
|
||||
Assert.Equal((byte)1, body[52]);
|
||||
Assert.True(player.TryGetOutboundPosition(out Position currentOutbound));
|
||||
Assert.False(player.ShouldSendPositionEvent(
|
||||
currentOutbound,
|
||||
player.ContactPlane,
|
||||
player.SimTimeSeconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImmediateAcknowledgement_RequiresSessionPlayerAndGroundContact()
|
||||
{
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000));
|
||||
var sent = new List<byte[]>();
|
||||
session.GameActionCapture = sent.Add;
|
||||
var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
|
||||
PlayerMovementController grounded = GroundedPlayer();
|
||||
|
||||
outbound.SendImmediatePosition(null, grounded);
|
||||
outbound.SendImmediatePosition(session, null);
|
||||
outbound.SendImmediatePosition(
|
||||
session,
|
||||
new PlayerMovementController(new PhysicsEngine()));
|
||||
|
||||
Assert.Empty(sent);
|
||||
}
|
||||
|
||||
private static PlayerMovementController GroundedPlayer()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
var player = new PlayerMovementController(engine);
|
||||
player.SetPosition(
|
||||
new Vector3(96f, 97f, 50f),
|
||||
0xA9B40001u,
|
||||
new Vector3(96f, 97f, 50f));
|
||||
Assert.True(player.CanSendPositionEvent);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static uint U32(byte[] body, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset, 4));
|
||||
|
||||
private static ushort U16(byte[] body, int offset) =>
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(offset, 2));
|
||||
|
||||
private static float F32(byte[] body, int offset) =>
|
||||
BitConverter.Int32BitsToSingle(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(offset, 4)));
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
public sealed class DeferredLiveEntityMotionRuntimeBindingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryReverseEdgeFailsBeforeBind()
|
||||
{
|
||||
var bridge = new DeferredLiveEntityMotionRuntimeBindings();
|
||||
var entity = Entity();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
bridge.GetSetupCylinder(1, entity));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
bridge.RouteServerMoveTo(null!, 0, default));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
bridge.StickToObjectFromWire(null, 1));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
bridge.ClearTargetForHiddenEntity(1));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
bridge.ResolvePhysicsHost(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindIsSingleAssignmentAndAllCallsReachExactOwner()
|
||||
{
|
||||
var bridge = new DeferredLiveEntityMotionRuntimeBindings();
|
||||
var owner = new RecordingBindings();
|
||||
bridge.Bind(owner);
|
||||
|
||||
Assert.Equal((2f, 3f), bridge.GetSetupCylinder(1, Entity()));
|
||||
Assert.True(bridge.RouteServerMoveTo(null!, 4, default));
|
||||
bridge.StickToObjectFromWire(null, 5);
|
||||
bridge.ClearTargetForHiddenEntity(6);
|
||||
Assert.Null(bridge.ResolvePhysicsHost(7));
|
||||
|
||||
Assert.Equal(
|
||||
["cylinder:1", "moveto:4", "stick:5", "hidden:6", "host:7"],
|
||||
owner.Calls);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
bridge.Bind(new RecordingBindings()));
|
||||
}
|
||||
|
||||
private static WorldEntity Entity() => new()
|
||||
{
|
||||
Id = 1,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private sealed class RecordingBindings : ILiveEntityMotionRuntimeBindings
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
public (float Radius, float Height) GetSetupCylinder(uint guid, WorldEntity entity)
|
||||
{
|
||||
Calls.Add($"cylinder:{guid}");
|
||||
return (2f, 3f);
|
||||
}
|
||||
public bool RouteServerMoveTo(
|
||||
MovementManager movement,
|
||||
uint cellId,
|
||||
WorldSession.EntityMotionUpdate update)
|
||||
{
|
||||
Calls.Add($"moveto:{cellId}");
|
||||
return true;
|
||||
}
|
||||
public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
|
||||
Calls.Add($"stick:{targetGuid}");
|
||||
public void ClearTargetForHiddenEntity(uint guid) => Calls.Add($"hidden:{guid}");
|
||||
public IPhysicsObjHost? ResolvePhysicsHost(uint guid)
|
||||
{
|
||||
Calls.Add($"host:{guid}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
public sealed class LiveEntityInboundAuthorityGateTests
|
||||
{
|
||||
private const uint Guid = 0x50000021u;
|
||||
|
||||
[Fact]
|
||||
public void Motion_StrictFreshnessAndWrapAreOwnedBeforePresentation()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 7, movement: 0xFFFE));
|
||||
var published = new List<AcceptedPhysicsTimestamps>();
|
||||
var gate = new LiveEntityInboundAuthorityGate(
|
||||
runtime,
|
||||
(_, timestamps) => published.Add(timestamps));
|
||||
|
||||
Assert.False(gate.TryAcceptMotion(
|
||||
Motion(instance: 7, movement: 0xFFFE, server: 1),
|
||||
retainPayload: true,
|
||||
out _,
|
||||
out bool equalAccepted));
|
||||
Assert.False(equalAccepted);
|
||||
|
||||
Assert.True(gate.TryAcceptMotion(
|
||||
Motion(instance: 7, movement: 1, server: 1),
|
||||
retainPayload: true,
|
||||
out AcceptedMotionNetworkUpdate wrapped,
|
||||
out bool wrapAccepted));
|
||||
Assert.True(wrapAccepted);
|
||||
Assert.Equal((ulong)2, wrapped.Record.MovementAuthorityVersion);
|
||||
Assert.Single(published);
|
||||
|
||||
Assert.False(gate.TryAcceptMotion(
|
||||
Motion(instance: 7, movement: 1, server: 1),
|
||||
retainPayload: true,
|
||||
out _,
|
||||
out bool duplicateAccepted));
|
||||
Assert.False(duplicateAccepted);
|
||||
Assert.Single(published);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutonomousLocalMotion_ConsumesTimestampButDoesNotPublishPayload()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 1));
|
||||
int publishCount = 0;
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++);
|
||||
|
||||
Assert.False(gate.TryAcceptMotion(
|
||||
Motion(instance: 1, movement: 2, server: 1, autonomous: true),
|
||||
retainPayload: false,
|
||||
out _,
|
||||
out bool timestampAccepted));
|
||||
|
||||
Assert.True(timestampAccepted);
|
||||
Assert.Equal(1, publishCount);
|
||||
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||
Assert.Equal((ulong)1, record.MovementAuthorityVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vector_InvalidPayloadDoesNotConsumeSequenceAndDuplicateIsRejected()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 2));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
var update = new VectorUpdate.Parsed(
|
||||
Guid,
|
||||
new Vector3(1f, 2f, 3f),
|
||||
Vector3.Zero,
|
||||
InstanceSequence: 2,
|
||||
VectorSequence: 2);
|
||||
|
||||
Assert.False(gate.TryAcceptVector(update, payloadIsValid: false, out _));
|
||||
Assert.True(gate.TryAcceptVector(
|
||||
update,
|
||||
payloadIsValid: true,
|
||||
out AcceptedVectorNetworkUpdate accepted));
|
||||
Assert.Equal((ulong)2, accepted.VectorAuthorityVersion);
|
||||
Assert.False(gate.TryAcceptVector(update, payloadIsValid: true, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vector_WrappedSequenceIsAccepted()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 2, vector: 0xFFFE));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
|
||||
Assert.True(gate.TryAcceptVector(
|
||||
new VectorUpdate.Parsed(
|
||||
Guid,
|
||||
Vector3.One,
|
||||
Vector3.Zero,
|
||||
InstanceSequence: 2,
|
||||
VectorSequence: 1),
|
||||
payloadIsValid: true,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_EqualIsRejectedAndWrappedSequenceIsAccepted()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 3, state: 0xFFFE));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
|
||||
Assert.False(gate.TryAcceptState(
|
||||
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 0xFFFE),
|
||||
out _));
|
||||
Assert.True(gate.TryAcceptState(
|
||||
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 1),
|
||||
out AcceptedStateNetworkUpdate accepted));
|
||||
Assert.Equal((ulong)2, accepted.StateAuthorityVersion);
|
||||
Assert.False(gate.TryAcceptState(
|
||||
new SetState.Parsed(Guid, 0, 3, 1),
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_UnknownLocalHintAndResetDoNotConsumeFuturePacket()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
int publishCount = 0;
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++);
|
||||
WorldSession.EntityPositionUpdate update = Position(
|
||||
Guid,
|
||||
instance: 4,
|
||||
position: 2,
|
||||
cell: 0xAABB0001u);
|
||||
|
||||
Assert.False(gate.TryAcceptPosition(
|
||||
update,
|
||||
Guid,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
payloadIsValid: true,
|
||||
out _));
|
||||
Assert.Equal(0xAABB0001u, gate.LastLivePlayerLandblockId);
|
||||
Assert.Equal(0, publishCount);
|
||||
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 4, position: 1));
|
||||
Assert.True(gate.TryAcceptPosition(
|
||||
update,
|
||||
Guid,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
payloadIsValid: true,
|
||||
out AcceptedPositionNetworkUpdate accepted));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition);
|
||||
Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
|
||||
Assert.Equal(1, publishCount);
|
||||
|
||||
gate.ResetSessionState();
|
||||
Assert.Null(gate.LastLivePlayerLandblockId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_InvalidPayloadDoesNotConsumeSequence()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
WorldSession.EntityPositionUpdate update = Position(Guid, 5, 2, 0x01010001u);
|
||||
|
||||
Assert.False(gate.TryAcceptPosition(
|
||||
update, Guid, Quaternion.Identity, Vector3.Zero, false, out _));
|
||||
Assert.True(gate.TryAcceptPosition(
|
||||
update, Guid, Quaternion.Identity, Vector3.Zero, true, out _));
|
||||
Assert.False(gate.TryAcceptPosition(
|
||||
update, Guid, Quaternion.Identity, Vector3.Zero, true, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_WrappedSequenceIsAccepted()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 0xFFFE));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
|
||||
Assert.True(gate.TryAcceptPosition(
|
||||
Position(Guid, 5, 1, 0x01010002u),
|
||||
Guid,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
payloadIsValid: true,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, vector: 1));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
Assert.True(gate.TryAcceptVector(
|
||||
new VectorUpdate.Parsed(Guid, Vector3.One, Vector3.Zero, 5, 2),
|
||||
payloadIsValid: true,
|
||||
out AcceptedVectorNetworkUpdate accepted));
|
||||
|
||||
Assert.True(runtime.TryApplyVector(
|
||||
new VectorUpdate.Parsed(Guid, Vector3.UnitX, Vector3.Zero, 5, 3),
|
||||
out _));
|
||||
Assert.False(runtime.IsCurrentVectorAuthority(
|
||||
accepted.Record,
|
||||
accepted.VectorAuthorityVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, state: 1));
|
||||
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||
Assert.True(gate.TryAcceptState(
|
||||
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 5, 2),
|
||||
out AcceptedStateNetworkUpdate accepted));
|
||||
|
||||
Assert.True(runtime.TryApplyState(
|
||||
new SetState.Parsed(Guid, 0, 5, 3),
|
||||
out _));
|
||||
Assert.False(runtime.IsCurrentStateAuthority(
|
||||
accepted.Record,
|
||||
accepted.StateAuthorityVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Motion_PublisherReplacementInvalidatesCapturedIncarnation()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 6));
|
||||
var gate = new LiveEntityInboundAuthorityGate(
|
||||
runtime,
|
||||
(_, _) => runtime.RegisterLiveEntity(Spawn(Guid, instance: 7)));
|
||||
|
||||
Assert.False(gate.TryAcceptMotion(
|
||||
Motion(instance: 6, movement: 2, server: 1),
|
||||
retainPayload: true,
|
||||
out _,
|
||||
out bool timestampAccepted));
|
||||
Assert.True(timestampAccepted);
|
||||
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord replacement));
|
||||
Assert.Equal((ushort)7, replacement.Generation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_PublisherNewerChannelInvalidatesCapturedAuthority()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 8, position: 1));
|
||||
var nested = Position(Guid, 8, 3, 0x01010002u);
|
||||
LiveEntityInboundAuthorityGate? gate = null;
|
||||
gate = new LiveEntityInboundAuthorityGate(
|
||||
runtime,
|
||||
(_, _) => runtime.TryApplyPosition(
|
||||
nested,
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
|
||||
Assert.False(gate.TryAcceptPosition(
|
||||
Position(Guid, 8, 2, 0x01010001u),
|
||||
Guid,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
payloadIsValid: true,
|
||||
out _));
|
||||
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||
Assert.Equal((ulong)3, record.PositionAuthorityVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_PublisherNewerVectorPreservesIndependentPositionAuthority()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 9, position: 1, vector: 1));
|
||||
var gate = new LiveEntityInboundAuthorityGate(
|
||||
runtime,
|
||||
(_, _) => runtime.TryApplyVector(
|
||||
new VectorUpdate.Parsed(
|
||||
Guid,
|
||||
Vector3.UnitX,
|
||||
Vector3.Zero,
|
||||
InstanceSequence: 9,
|
||||
VectorSequence: 2),
|
||||
out _));
|
||||
|
||||
Assert.True(gate.TryAcceptPosition(
|
||||
Position(Guid, 9, 2, 0x01010003u),
|
||||
Guid,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
payloadIsValid: true,
|
||||
out AcceptedPositionNetworkUpdate accepted));
|
||||
Assert.True(runtime.IsCurrentPositionAuthority(
|
||||
accepted.Record,
|
||||
accepted.PositionAuthorityVersion));
|
||||
Assert.False(runtime.IsCurrentVelocityAuthority(
|
||||
accepted.Record,
|
||||
accepted.VelocityAuthorityVersion));
|
||||
}
|
||||
|
||||
private static LiveEntityRuntime Runtime() => new(
|
||||
new GpuWorldState(),
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||
|
||||
private static WorldSession.EntityMotionUpdate Motion(
|
||||
ushort instance,
|
||||
ushort movement,
|
||||
ushort server,
|
||||
bool autonomous = false) => new(
|
||||
Guid,
|
||||
default,
|
||||
instance,
|
||||
movement,
|
||||
server,
|
||||
autonomous);
|
||||
|
||||
private static WorldSession.EntityPositionUpdate Position(
|
||||
uint guid,
|
||||
ushort instance,
|
||||
ushort position,
|
||||
uint cell) => new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
cell, 10f, 11f, 12f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: instance,
|
||||
PositionSequence: position,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort instance,
|
||||
ushort position = 1,
|
||||
ushort movement = 1,
|
||||
ushort state = 1,
|
||||
ushort vector = 1)
|
||||
{
|
||||
var serverPosition = new CreateObject.ServerPosition(
|
||||
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
position,
|
||||
movement,
|
||||
state,
|
||||
vector,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: instance);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: serverPosition,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
serverPosition,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: movement,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: position,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
using AcDream.App.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
public sealed class LiveEntityNetworkBranchRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Vector_ProjectileStopsCanonicalAndOrdinaryRoutes()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
|
||||
LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
|
||||
() => { calls.Add("projectile"); return true; },
|
||||
() => { calls.Add("canonical"); return true; },
|
||||
() => calls.Add("ordinary"));
|
||||
|
||||
Assert.Equal(LiveEntityVectorRoute.Projectile, route);
|
||||
Assert.Equal(["projectile"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vector_CanonicalBodyRunsOnlyAfterProjectileDeclines()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
|
||||
LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
|
||||
() => { calls.Add("projectile"); return false; },
|
||||
() => { calls.Add("canonical"); return true; },
|
||||
() => calls.Add("ordinary"));
|
||||
|
||||
Assert.Equal(LiveEntityVectorRoute.CanonicalBody, route);
|
||||
Assert.Equal(["projectile", "canonical"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vector_OrdinaryRemoteIsTheLastFallback()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
|
||||
LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
|
||||
() => { calls.Add("projectile"); return false; },
|
||||
() => { calls.Add("canonical"); return false; },
|
||||
() => calls.Add("ordinary"));
|
||||
|
||||
Assert.Equal(LiveEntityVectorRoute.OrdinaryRemote, route);
|
||||
Assert.Equal(["projectile", "canonical", "ordinary"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForcePosition_BlipsAndAcknowledgesExactlyOnce()
|
||||
{
|
||||
int currentChecks = 0;
|
||||
int blips = 0;
|
||||
int acknowledgements = 0;
|
||||
|
||||
bool completed = LocalForcePositionTransaction.Apply(
|
||||
isForcePosition: true,
|
||||
() => { currentChecks++; return true; },
|
||||
() => blips++,
|
||||
() => acknowledgements++);
|
||||
|
||||
Assert.True(completed);
|
||||
Assert.Equal(2, currentChecks);
|
||||
Assert.Equal(1, blips);
|
||||
Assert.Equal(1, acknowledgements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForcePosition_AcknowledgementInvalidationStopsTheTail()
|
||||
{
|
||||
bool current = true;
|
||||
int acknowledgements = 0;
|
||||
|
||||
bool completed = LocalForcePositionTransaction.Apply(
|
||||
isForcePosition: true,
|
||||
() => current,
|
||||
() => { },
|
||||
() => { acknowledgements++; current = false; });
|
||||
|
||||
Assert.False(completed);
|
||||
Assert.Equal(1, acknowledgements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryPositionDoesNotBlipOrAcknowledge()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
Assert.True(LocalForcePositionTransaction.Apply(
|
||||
isForcePosition: false,
|
||||
() => { calls++; return false; },
|
||||
() => calls++,
|
||||
() => calls++));
|
||||
Assert.Equal(0, calls);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,23 @@ public sealed class GameWindowLiveEntityCompositionTests
|
|||
[InlineData("OnLiveEntityPruned")]
|
||||
[InlineData("TearDownLiveEntityRuntimeComponents")]
|
||||
[InlineData("CreateLiveEntityRuntimeTeardownPlan")]
|
||||
[InlineData("RouteSameGenerationCreateObject")]
|
||||
[InlineData("OnLiveMotionUpdated")]
|
||||
[InlineData("OnLivePositionUpdated")]
|
||||
[InlineData("OnLiveVectorUpdated")]
|
||||
[InlineData("OnLiveStateUpdated")]
|
||||
[InlineData("EnsureRemoteMotionBindings")]
|
||||
[InlineData("ResolvePhysicsHost")]
|
||||
[InlineData("GetSetupCylinder")]
|
||||
[InlineData("RouteServerMoveTo")]
|
||||
[InlineData("StickToObjectFromWire")]
|
||||
[InlineData("ClearTargetForHiddenEntity")]
|
||||
[InlineData("ApplyServerControlledVelocityCycle")]
|
||||
[InlineData("RunRemoteTeleportHook")]
|
||||
[InlineData("SendImmediateLocalPositionEvent")]
|
||||
[InlineData("DispatchRemoteInboundMotion")]
|
||||
[InlineData("CreateRemoteMotion")]
|
||||
[InlineData("WillAdvanceRemoteMotion")]
|
||||
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
||||
{
|
||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
||||
|
|
@ -52,6 +69,10 @@ public sealed class GameWindowLiveEntityCompositionTests
|
|||
[InlineData(typeof(LiveEntityHydrationController))]
|
||||
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
|
||||
[InlineData(typeof(LiveEntityRuntimeTeardownController))]
|
||||
[InlineData(typeof(LiveEntityNetworkUpdateController))]
|
||||
[InlineData(typeof(LiveEntityMotionRuntimeController))]
|
||||
[InlineData(typeof(LiveEntityInboundAuthorityGate))]
|
||||
[InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))]
|
||||
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
|
||||
{
|
||||
foreach (FieldInfo field in helperType.GetFields(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class LiveEntitySameGenerationUpdateRouterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, "parent")]
|
||||
[InlineData(1, "position")]
|
||||
[InlineData(2, "pickup")]
|
||||
[InlineData(3, null)]
|
||||
public void Apply_PreservesRetailTailOrderAndExclusiveSpatialBranch(
|
||||
int spatialKind,
|
||||
string? expectedSpatial)
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
SameGenerationCreateObjectEvents refresh = new(
|
||||
Description: default,
|
||||
Appearance: default,
|
||||
Parent: spatialKind == 0 ? default(CreateParentUpdate?) : null,
|
||||
Position: spatialKind == 1
|
||||
? default(WorldSession.EntityPositionUpdate?)
|
||||
: null,
|
||||
Pickup: spatialKind == 2 ? default(PickupEvent.Parsed?) : null,
|
||||
Movement: default(WorldSession.EntityMotionUpdate?),
|
||||
State: default,
|
||||
Vector: default);
|
||||
|
||||
// Nullable default(T?) has no value; explicitly wrap the selected
|
||||
// zero-valued fixture so the branch itself remains observable.
|
||||
refresh = refresh with
|
||||
{
|
||||
Parent = spatialKind == 0
|
||||
? (CreateParentUpdate?)new CreateParentUpdate()
|
||||
: null,
|
||||
Position = spatialKind == 1
|
||||
? (WorldSession.EntityPositionUpdate?)new WorldSession.EntityPositionUpdate()
|
||||
: null,
|
||||
Pickup = spatialKind == 2
|
||||
? (PickupEvent.Parsed?)new PickupEvent.Parsed()
|
||||
: null,
|
||||
Movement = new WorldSession.EntityMotionUpdate(),
|
||||
};
|
||||
|
||||
LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink);
|
||||
|
||||
var expected = new List<string> { "description", "appearance" };
|
||||
if (expectedSpatial is not null)
|
||||
expected.Add(expectedSpatial);
|
||||
expected.AddRange(["movement", "state", "vector"]);
|
||||
Assert.Equal(expected, sink.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentTakesPrecedenceWhenMalformedFixtureOffersAllSpatialForms()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var refresh = new SameGenerationCreateObjectEvents(
|
||||
default,
|
||||
default,
|
||||
new CreateParentUpdate(),
|
||||
new WorldSession.EntityPositionUpdate(),
|
||||
new PickupEvent.Parsed(),
|
||||
null,
|
||||
default,
|
||||
default);
|
||||
|
||||
LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink);
|
||||
|
||||
Assert.Equal(
|
||||
["description", "appearance", "parent", "state", "vector"],
|
||||
sink.Events);
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : ILiveEntitySameGenerationUpdateSink
|
||||
{
|
||||
public List<string> Events { get; } = [];
|
||||
public void OnDescription(uint ownerGuid, PhysicsSpawnData description) =>
|
||||
Events.Add("description");
|
||||
public void OnAppearance(ObjDescEvent.Parsed appearance) =>
|
||||
Events.Add("appearance");
|
||||
public void OnParent(CreateParentUpdate parent) => Events.Add("parent");
|
||||
public void OnPosition(WorldSession.EntityPositionUpdate position) =>
|
||||
Events.Add("position");
|
||||
public void OnPickup(PickupEvent.Parsed pickup) => Events.Add("pickup");
|
||||
public void OnMovement(WorldSession.EntityMotionUpdate movement) =>
|
||||
Events.Add("movement");
|
||||
public void OnState(SetState.Parsed state) => Events.Add("state");
|
||||
public void OnVector(VectorUpdate.Parsed vector) => Events.Add("vector");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue