This commit is contained in:
SepComet
2026-04-06 11:58:36 +08:00
parent b1b38b485e
commit aebc4011c7
84 changed files with 5796 additions and 238 deletions
@@ -1,4 +1,5 @@
using System.Net;
using System.Reflection;
using Network.Defines;
using Network.NetworkApplication;
using NUnit.Framework;
@@ -125,6 +126,71 @@ namespace Tests.EditMode.Network
Assert.That(combatPresentation.IsDead, Is.False);
}
[Test]
public void ClientGameplayFlow_ControlledPlayerReconciliation_ReplacesActiveCorrectionForConsecutiveSmallSnapshots()
{
var gameObject = new GameObject("controlled-player");
try
{
var rigidbody = gameObject.AddComponent<Rigidbody>();
rigidbody.useGravity = false;
var movement = gameObject.AddComponent<MovementComponent>();
typeof(MovementComponent)
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(movement, rigidbody);
movement.Init(true, master: null, speed: 10, serverTick: 0);
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapAfterFailedConvergence()
{
var gameObject = new GameObject("controlled-player");
try
{
var rigidbody = gameObject.AddComponent<Rigidbody>();
rigidbody.useGravity = false;
var movement = gameObject.AddComponent<MovementComponent>();
typeof(MovementComponent)
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(movement, rigidbody);
movement.Init(true, master: null, speed: 10, serverTick: 0);
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1.25f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
GameplayFlowTestSupport.CreatePlayerState("player-1", 3, new Vector3(1.75f, 0f, 0f), acknowledgedMoveTick: 0)));
InvokeControlledFixedUpdate(movement);
Assert.That(rigidbody.position.x, Is.EqualTo(1.75f).Within(0.0001f));
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ClientGameplayHarness_RemotePlayerStateFlow_RejectsStaleSnapshots_AndUsesInterpolationOrLatestClamp()
{
@@ -157,5 +223,11 @@ namespace Tests.EditMode.Network
Assert.That(clamped.LatestSnapshot.Tick, Is.EqualTo(11));
Assert.That(clamped.Position, Is.EqualTo(new Vector3(10f, 0f, 0f)));
}
private static void InvokeControlledFixedUpdate(MovementComponent movement)
{
typeof(MovementComponent)
.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.NonPublic)
.Invoke(movement, null);
}
}
}
@@ -96,6 +96,7 @@ namespace Tests.EditMode.Network
TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender);
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(serverRuntime.AuthoritativeMovementCadence, Is.EqualTo(TimeSpan.FromMilliseconds(50)));
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localServerState), Is.True);
Assert.That(localServerState.PlayerId, Is.EqualTo("player-a"));
Assert.That(localServerState.PositionX, Is.EqualTo(0.5f).Within(0.0001f));
@@ -105,6 +106,7 @@ namespace Tests.EditMode.Network
Assert.That(clientHarness.TryGetState("player-a", out var localClientState), Is.True);
Assert.That(localClientState.Tick, Is.EqualTo(1));
Assert.That(localClientState.AcknowledgedMoveTick, Is.EqualTo(1));
Assert.That(localClientState.Position.x, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(clientHarness.TryGetState("player-b", out var remoteClientState), Is.True);
Assert.That(remoteClientState.Hp, Is.EqualTo(70));
@@ -177,6 +179,7 @@ namespace Tests.EditMode.Network
Assert.That(clientHarness.TryGetState("player-a", out var idleLocalState), Is.True);
Assert.That(idleLocalState.Tick, Is.EqualTo(1));
Assert.That(idleLocalState.AcknowledgedMoveTick, Is.EqualTo(0));
Assert.That(idleLocalState.Position.x, Is.EqualTo(0f).Within(0.0001f));
Assert.That(idleLocalState.Position.z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(idleLocalState.Hp, Is.EqualTo(100));
@@ -213,7 +213,7 @@ namespace Tests.EditMode.Network
}.ToByteArray();
}
public static PlayerState CreatePlayerState(string playerId, long tick, Vector3 position, int hp = 100, float rotation = 0f, Vector3? velocity = null)
public static PlayerState CreatePlayerState(string playerId, long tick, Vector3 position, int hp = 100, float rotation = 0f, Vector3? velocity = null, long acknowledgedMoveTick = 0)
{
var resolvedVelocity = velocity ?? Vector3.zero;
return new PlayerState
@@ -223,7 +223,8 @@ namespace Tests.EditMode.Network
Position = new global::Network.Defines.Vector3 { X = position.x, Y = position.y, Z = position.z },
Velocity = new global::Network.Defines.Vector3 { X = resolvedVelocity.x, Y = resolvedVelocity.y, Z = resolvedVelocity.z },
Rotation = rotation,
Hp = hp
Hp = hp,
AcknowledgedMoveTick = acknowledgedMoveTick
};
}
}
@@ -16,6 +16,55 @@ namespace Tests.EditMode.Network
private static readonly IPEndPoint PeerA = new(IPAddress.Loopback, 9101);
private static readonly IPEndPoint PeerB = new(IPAddress.Loopback, 9102);
[Test]
public void UpdateAuthoritativeMovement_UsesConfiguredSimulationCadence_AndExposesItOnRuntime()
{
var createdTransports = new Dictionary<int, FakeTransport>();
var configuration = new ServerRuntimeConfiguration(9000)
{
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port => CreateTransport(createdTransports, port),
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = 4f,
SimulationInterval = TimeSpan.FromMilliseconds(50),
BroadcastInterval = TimeSpan.FromMilliseconds(100)
}
};
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
runtime.Host.NotifyLoginStarted(PeerA);
runtime.Host.NotifyLoginSucceeded(PeerA, "player-a");
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-a",
Tick = 1,
TurnInput = 0f,
ThrottleInput = 1f
}), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(49));
Assert.That(runtime.AuthoritativeMovementCadence, Is.EqualTo(TimeSpan.FromMilliseconds(50)));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateBeforeCadence), Is.True);
Assert.That(stateBeforeCadence.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(1));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterFirstStep), Is.True);
Assert.That(stateAfterFirstStep.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterSecondStep), Is.True);
Assert.That(stateAfterSecondStep.PositionX, Is.EqualTo(0.4f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
}
[Test]
public void UpdateAuthoritativeMovement_AcceptsLatestTickPerPeer_AndKeepsStaleFilteringIndependent()
{
@@ -118,6 +167,7 @@ namespace Tests.EditMode.Network
var firstBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[0]);
Assert.That(firstBroadcast.PlayerId, Is.EqualTo("player-a"));
Assert.That(firstBroadcast.Tick, Is.EqualTo(1));
Assert.That(firstBroadcast.AcknowledgedMoveTick, Is.EqualTo(1));
Assert.That(firstBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(10f).Within(0.0001f));
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
@@ -141,6 +191,7 @@ namespace Tests.EditMode.Network
var secondBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[1]);
Assert.That(secondBroadcast.Tick, Is.EqualTo(2));
Assert.That(secondBroadcast.AcknowledgedMoveTick, Is.EqualTo(2));
Assert.That(secondBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
@@ -186,6 +237,7 @@ namespace Tests.EditMode.Network
var broadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[0]);
Assert.That(broadcast.PlayerId, Is.EqualTo("player-a"));
Assert.That(broadcast.Tick, Is.EqualTo(1));
Assert.That(broadcast.AcknowledgedMoveTick, Is.EqualTo(0));
Assert.That(broadcast.Position.X, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
@@ -228,6 +280,7 @@ namespace Tests.EditMode.Network
var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]);
Assert.That(broadcast.Tick, Is.EqualTo(1));
Assert.That(broadcast.AcknowledgedMoveTick, Is.EqualTo(5));
Assert.That(broadcast.Position.X, Is.EqualTo(-0.3f).Within(0.0001f));
Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.X, Is.EqualTo(-6f).Within(0.0001f));
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using Google.Protobuf;
using Network.Defines;
using Network.NetworkApplication;
@@ -90,11 +91,12 @@ namespace Tests.EditMode.Network
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f });
var accepted = buffer.TryApplyAuthoritativeState(
new PlayerState { PlayerId = "player-1", Tick = 11 },
new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 },
out var replayInputs);
Assert.That(accepted, Is.True);
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(11));
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11));
Assert.That(replayInputs.Count, Is.EqualTo(1));
Assert.That(replayInputs[0].Input.Tick, Is.EqualTo(12));
Assert.That(replayInputs[0].SimulatedDurationSeconds, Is.EqualTo(0f));
@@ -106,15 +108,121 @@ namespace Tests.EditMode.Network
{
var buffer = new ClientPredictionBuffer();
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10 }, out _);
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10, AcknowledgedMoveTick = 10 }, out _);
var accepted = buffer.TryApplyAuthoritativeState(
new PlayerState { PlayerId = "player-1", Tick = 9 },
new PlayerState { PlayerId = "player-1", Tick = 9, AcknowledgedMoveTick = 9 },
out var replayInputs);
Assert.That(accepted, Is.False);
Assert.That(replayInputs, Is.Empty);
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10));
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(10));
}
[Test]
public void ClientMovementBootstrap_LoginResponse_UsesServerConfirmedMovementParameters()
{
var bootstrap = ClientMovementBootstrap.FromLoginResponse(new LoginResponse
{
Speed = 12,
ServerTick = 34
});
Assert.That(bootstrap.AuthoritativeMoveSpeed, Is.EqualTo(12));
Assert.That(bootstrap.ServerTick, Is.EqualTo(34));
}
[Test]
public void ControlledPlayerCorrection_SmallError_UsesBoundedCorrection()
{
var result = ControlledPlayerCorrection.Resolve(
Vector3.zero,
Quaternion.identity,
new Vector3(0.75f, 0f, 0f),
Quaternion.Euler(0f, 15f, 0f),
new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f));
Assert.That(result.UsedHardSnap, Is.False);
Assert.That(result.Position, Is.EqualTo(new Vector3(0.5f, 0f, 0f)));
Assert.That(result.Rotation.eulerAngles.y, Is.EqualTo(9f).Within(0.01f));
}
[Test]
public void ControlledPlayerCorrection_LargeError_UsesHardSnap()
{
var targetRotation = Quaternion.Euler(0f, 40f, 0f);
var result = ControlledPlayerCorrection.Resolve(
Vector3.zero,
Quaternion.identity,
new Vector3(2f, 0f, 0f),
targetRotation,
new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f));
Assert.That(result.UsedHardSnap, Is.True);
Assert.That(result.Position, Is.EqualTo(new Vector3(2f, 0f, 0f)));
Assert.That(result.Rotation.eulerAngles.y, Is.EqualTo(targetRotation.eulerAngles.y).Within(0.01f));
Assert.That(result.NextState.IsActive, Is.False);
}
[Test]
public void ControlledPlayerCorrection_RepeatedSmallCorrections_UpdateActiveCorrectionState()
{
var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f);
var first = ControlledPlayerCorrection.Resolve(
Vector3.zero,
Quaternion.identity,
new Vector3(0.75f, 0f, 0f),
Quaternion.identity,
settings);
var second = ControlledPlayerCorrection.Resolve(
first.Position,
first.Rotation,
new Vector3(1f, 0f, 0f),
Quaternion.identity,
settings,
first.NextState);
Assert.That(first.UsedHardSnap, Is.False);
Assert.That(first.NextState.IsActive, Is.True);
Assert.That(first.NextState.RemainingStepBudget, Is.EqualTo(2));
Assert.That(second.UsedHardSnap, Is.False);
Assert.That(second.Position.x, Is.EqualTo(1f).Within(0.0001f));
Assert.That(second.NextState.IsActive, Is.False);
}
[Test]
public void ControlledPlayerCorrection_RepeatedNonConvergentSmallCorrections_EventuallyUseHardSnap()
{
var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f);
var first = ControlledPlayerCorrection.Resolve(
Vector3.zero,
Quaternion.identity,
new Vector3(0.75f, 0f, 0f),
Quaternion.identity,
settings);
var second = ControlledPlayerCorrection.Resolve(
first.Position,
first.Rotation,
new Vector3(1.25f, 0f, 0f),
Quaternion.identity,
settings,
first.NextState);
var third = ControlledPlayerCorrection.Resolve(
second.Position,
second.Rotation,
new Vector3(1.75f, 0f, 0f),
Quaternion.identity,
settings,
second.NextState);
Assert.That(first.UsedHardSnap, Is.False);
Assert.That(second.UsedHardSnap, Is.False);
Assert.That(second.NextState.IsActive, Is.True);
Assert.That(second.NextState.RemainingStepBudget, Is.EqualTo(1));
Assert.That(third.UsedHardSnap, Is.True);
Assert.That(third.Position.x, Is.EqualTo(1.75f).Within(0.0001f));
Assert.That(third.NextState.IsActive, Is.False);
}
[Test]
@@ -129,7 +237,8 @@ namespace Tests.EditMode.Network
Position = new global::Network.Defines.Vector3 { X = 5f, Y = 0f, Z = -3f },
Velocity = new global::Network.Defines.Vector3 { X = 1.5f, Y = 0f, Z = 0.25f },
Rotation = 90f,
Hp = 73
Hp = 73,
AcknowledgedMoveTick = 9
},
out var snapshot);
@@ -137,6 +246,7 @@ namespace Tests.EditMode.Network
Assert.That(owner.Current, Is.SameAs(snapshot));
Assert.That(snapshot.PlayerId, Is.EqualTo("player-1"));
Assert.That(snapshot.Tick, Is.EqualTo(14));
Assert.That(snapshot.AcknowledgedMoveTick, Is.EqualTo(9));
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(5f, 0f, -3f)));
Assert.That(snapshot.Velocity, Is.EqualTo(new Vector3(1.5f, 0f, 0.25f)));
Assert.That(snapshot.Rotation, Is.EqualTo(90f));
@@ -360,7 +470,7 @@ namespace Tests.EditMode.Network
Assert.That(sample.HasValue, Is.True);
Assert.That(sample.UsedInterpolation, Is.False);
Assert.That(sample.Position, Is.EqualTo(new Vector3(2f, 0f, -1f)));
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(15f).Within(0.01f));
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(75f).Within(0.01f));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(12));
}
@@ -392,6 +502,172 @@ namespace Tests.EditMode.Network
Assert.That(runtime.ClockSync.CurrentServerTick, Is.EqualTo(88));
}
[Test]
public void ReplayPendingInputs_StepByStepMatchesAccumulated_ForZeroTurnInput()
{
// Arrange: set up MovementComponent with initial state.
var gameObject = new GameObject("replay-test");
try
{
var rigidbody = gameObject.AddComponent<Rigidbody>();
rigidbody.useGravity = false;
var movement = gameObject.AddComponent<MovementComponent>();
typeof(MovementComponent)
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(movement, rigidbody);
movement.Init(true, master: null, speed: 10, serverTick: 0);
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
var turnInput = 0f;
var throttleInput = 1f;
var stepDuration = 0.05f;
var totalDuration = stepDuration * 3; // 0.15s
// Act — step-by-step path (live prediction shape).
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps: 3);
var stepByStepPosition = rigidbody.position;
var stepByStepRotation = rigidbody.rotation;
// Reset to initial state.
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
// Act — accumulated replay shape.
var accumulatedReplayInputs = new List<PredictedMoveStep>
{
new PredictedMoveStep(
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
totalDuration)
};
InvokeReplayPendingInputs(movement, accumulatedReplayInputs);
var accumulatedPosition = rigidbody.position;
var accumulatedRotation = rigidbody.rotation;
// Assert: for straight movement (turn=0), both paths should be identical.
Assert.That(Vector3.Distance(accumulatedPosition, stepByStepPosition), Is.LessThan(0.0001f),
"Accumulated replay produced a different position than step-by-step for straight movement.");
Assert.That(Quaternion.Angle(accumulatedRotation, stepByStepRotation), Is.LessThan(0.01f),
"Accumulated replay produced a different rotation than step-by-step for straight movement.");
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ReplayPendingInputs_StepByStepDiffersFromAccumulated_ForNonZeroTurnInput()
{
// Arrange: use many small steps and a large turn input to make non-linearity visible.
var gameObject = new GameObject("replay-test");
try
{
var rigidbody = gameObject.AddComponent<Rigidbody>();
rigidbody.useGravity = false;
var movement = gameObject.AddComponent<MovementComponent>();
typeof(MovementComponent)
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(movement, rigidbody);
movement.Init(true, master: null, speed: 10, serverTick: 0);
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
// Use 20 substeps of 0.05s (1 second total) with full turn.
// This amplifies the non-linearity so the one-shot and step-by-step diverge.
var turnInput = 1f;
var throttleInput = 1f;
var stepDuration = 0.05f;
var steps = 20;
var totalDuration = stepDuration * steps;
// Act — step-by-step (correct approach).
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps);
var stepByStepPosition = rigidbody.position;
// Reset.
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
// Act — ONE big step simulating the old buggy accumulated behavior.
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, totalDuration, steps: 1);
var oneShotPosition = rigidbody.position;
// Assert: for non-zero turn with many steps, the old one-shot and correct step-by-step MUST differ.
Assert.That(Vector3.Distance(oneShotPosition, stepByStepPosition), Is.GreaterThan(0.001f),
"One-shot accumulated and step-by-step produced the same result for turn input — " +
"the non-linearity should cause a visible divergence with many steps.");
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration()
{
// Arrange: simulate 0.12s — not a multiple of 50ms.
var gameObject = new GameObject("replay-test");
try
{
var rigidbody = gameObject.AddComponent<Rigidbody>();
rigidbody.useGravity = false;
var movement = gameObject.AddComponent<MovementComponent>();
typeof(MovementComponent)
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(movement, rigidbody);
movement.Init(true, master: null, speed: 10, serverTick: 0);
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
var turnInput = 0f;
var throttleInput = 1f;
var totalDuration = 0.12f; // 0.05 + 0.05 + 0.02
var replayInputs = new List<PredictedMoveStep>
{
new PredictedMoveStep(
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
totalDuration)
};
InvokeReplayPendingInputs(movement, replayInputs);
var finalPosition = rigidbody.position;
// Expected: 0.12s at speed=10 → 1.2 units forward.
var expectedPosition = new Vector3(0f, 0f, 1.2f);
Assert.That(finalPosition.z, Is.EqualTo(expectedPosition.z).Within(0.0001f),
"Non-multiple of cadence (0.12s) had remaining duration lost or misapplied.");
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
private static void ApplyTankMovementStepByStep(MovementComponent movement, float turnInput, float throttleInput, float stepDuration, int steps)
{
var method = typeof(MovementComponent)
.GetMethod("ApplyTankMovement", BindingFlags.Instance | BindingFlags.NonPublic);
for (var i = 0; i < steps; i++)
{
method.Invoke(movement, new object[] { turnInput, throttleInput, stepDuration });
}
}
private static void ResetMovementState(Rigidbody rigidbody, Vector3 position, Quaternion rotation)
{
rigidbody.position = position;
rigidbody.rotation = rotation;
rigidbody.velocity = Vector3.zero;
rigidbody.angularVelocity = Vector3.zero;
}
private static void InvokeReplayPendingInputs(MovementComponent movement, IReadOnlyList<PredictedMoveStep> inputs)
{
typeof(MovementComponent)
.GetMethod("ReplayPendingInputs", BindingFlags.Instance | BindingFlags.NonPublic)
.Invoke(movement, new object[] { inputs });
}
[Test]
public void ServerNetworkHost_RejectsStaleMoveInputPerPeerWithoutCrossPeerInterference()
{