diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs
index bc86c1fa4..bfece4ee7 100644
--- a/Robust.Client/GameStates/ClientGameStateManager.cs
+++ b/Robust.Client/GameStates/ClientGameStateManager.cs
@@ -1,11 +1,9 @@
-using System.Collections.Generic;
-using Robust.Client.Interfaces;
+using Robust.Client.Interfaces;
using Robust.Client.Interfaces.GameObjects;
using Robust.Client.Interfaces.GameStates;
using Robust.Shared.GameStates;
using Robust.Shared.Interfaces.Network;
using Robust.Shared.IoC;
-using Robust.Shared.Log;
using Robust.Shared.Network.Messages;
using Robust.Client.Player;
using Robust.Shared.Configuration;
@@ -13,20 +11,14 @@ using Robust.Shared.Interfaces.Configuration;
using Robust.Shared.Interfaces.Map;
using Robust.Shared.Interfaces.Timing;
using Robust.Shared.Timing;
-using Robust.Shared.Utility;
namespace Robust.Client.GameStates
{
///
public class ClientGameStateManager : IClientGameStateManager
{
- private readonly List _stateBuffer = new List();
- private GameState _lastFullState;
- private bool _waitingForFull = true;
- private bool _interpEnabled;
- private int _interpRatio;
- private bool _logging;
-
+ private GameStateProcessor _processor;
+
[Dependency] private readonly IClientEntityManager _entities;
[Dependency] private readonly IPlayerManager _players;
[Dependency] private readonly IClientNetManager _network;
@@ -36,41 +28,38 @@ namespace Robust.Client.GameStates
[Dependency] private readonly IConfigurationManager _config;
///
- public int MinBufferSize => _interpEnabled ? 3 : 2;
+ public int MinBufferSize => _processor.MinBufferSize;
///
- public int TargetBufferSize => MinBufferSize + _interpRatio;
+ public int TargetBufferSize => _processor.TargetBufferSize;
///
public void Initialize()
{
+ _processor = new GameStateProcessor(_timing);
+
_network.RegisterNetMessage(MsgState.NAME, HandleStateMessage);
_network.RegisterNetMessage(MsgStateAck.NAME);
_client.RunLevelChanged += RunLevelChanged;
if(!_config.IsCVarRegistered("net.interp"))
- _config.RegisterCVar("net.interp", false, CVar.ARCHIVE, b => _interpEnabled = b);
+ _config.RegisterCVar("net.interp", false, CVar.ARCHIVE, b => _processor.Interpolation = b);
if (!_config.IsCVarRegistered("net.interp_ratio"))
- _config.RegisterCVar("net.interp_ratio", 0, CVar.ARCHIVE, i => _interpRatio = i < 0 ? 0 : i);
+ _config.RegisterCVar("net.interp_ratio", 0, CVar.ARCHIVE, i => _processor.InterpRatio = i);
if (!_config.IsCVarRegistered("net.logging"))
- _config.RegisterCVar("net.logging", false, CVar.ARCHIVE, b => _logging = b);
+ _config.RegisterCVar("net.logging", false, CVar.ARCHIVE, b => _processor.Logging = b);
- _interpEnabled = _config.GetCVar("net.interp");
-
- _interpRatio = _config.GetCVar("net.interp_ratio");
- _interpRatio = _interpRatio < 0 ? 0 : _interpRatio; // min bound, < 0 makes no sense
-
- _logging = _config.GetCVar("net.logging");
+ _processor.Interpolation = _config.GetCVar("net.interp");
+ _processor.InterpRatio = _config.GetCVar("net.interp_ratio");
+ _processor.Logging = _config.GetCVar("net.logging");
}
///
public void Reset()
{
- _stateBuffer.Clear();
- _lastFullState = null;
- _waitingForFull = true;
+ _processor.Reset();
}
private void RunLevelChanged(object sender, RunLevelChangedEventArgs args)
@@ -78,7 +67,7 @@ namespace Robust.Client.GameStates
if (args.NewLevel == ClientRunLevel.Initialize)
{
// We JUST left a server or the client started up, Reset everything.
- _stateBuffer.Clear();
+ Reset();
}
}
@@ -86,207 +75,19 @@ namespace Robust.Client.GameStates
{
var state = message.State;
+ _processor.AddNewState(state, message.MsgSize);
+
// we always ack everything we receive, even if it is late
AckGameState(state.ToSequence);
-
- // any state from tick 0 is a full state, and needs to be handled different
- if (state.FromSequence == GameTick.Zero)
- {
- // this is a newer full state, so discard the older one.
- if(_lastFullState == null || (_lastFullState != null && _lastFullState.ToSequence < state.ToSequence))
- {
- _lastFullState = state;
-
- if(_logging)
- Logger.InfoS("net", $"Received Full GameState: to={state.ToSequence}, sz={message.MsgSize}");
-
- return;
- }
- }
-
- // NOTE: DispatchTick will be modifying CurTick, this is NOT thread safe.
- var lastTick = new GameTick(_timing.CurTick.Value - 1);
-
- if (state.ToSequence <= lastTick && !_waitingForFull) // CurTick isn't set properly when WaitingForFull
- {
- if (_logging)
- Logger.DebugS("net.state", $"Received Old GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={message.MsgSize}, buf={_stateBuffer.Count}");
-
- return;
- }
-
- // lets check for a duplicate state now.
- for (var i = 0; i < _stateBuffer.Count; i++)
- {
- var iState = _stateBuffer[i];
-
- if (state.ToSequence != iState.ToSequence)
- continue;
-
- if (iState.Extrapolated)
- {
- _stateBuffer.RemoveSwap(i); // remove the fake extrapolated state
- break; // break from the loop, add the new state normally
- }
-
- if (_logging)
- Logger.DebugS("net.state", $"Received Dupe GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={message.MsgSize}, buf={_stateBuffer.Count}");
-
- return;
- }
-
- // this is a good state that we will be using.
- _stateBuffer.Add(state);
-
- if (_logging)
- Logger.DebugS("net.state", $"Received New GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={message.MsgSize}, buf={_stateBuffer.Count}");
}
-
+
///
public void ApplyGameState()
{
- if (CalculateNextStates(_timing.CurTick, out var curState, out var nextState, TargetBufferSize))
- {
- if (_logging)
- Logger.DebugS("net.state", $"Applying State: ext={curState.Extrapolated}, cTick={_timing.CurTick}, fSeq={curState.FromSequence}, tSeq={curState.ToSequence}, buf={_stateBuffer.Count}");
+ if (!_processor.ProcessTickStates(_timing.CurTick, out var curState, out var nextState))
+ return;
- ApplyGameState(curState, nextState);
- }
-
- if (!_waitingForFull)
- {
- // This will slightly speed up or slow down the client tickrate based on the contents of the buffer.
- // CalcNextState should have just cleaned out any old states, so the buffer contains [t-1(last), t+0(cur), t+1(next), t+2, t+3, ..., t+n]
- // we can use this info to properly time our tickrate so it does not run fast or slow compared to the server.
- _timing.TickTimingAdjustment = (_stateBuffer.Count - (float) TargetBufferSize) * 0.10f;
- }
- else
- {
- _timing.TickTimingAdjustment = 0f;
- }
- }
-
- private bool CalculateNextStates(GameTick curTick, out GameState curState, out GameState nextState, int targetBufferSize)
- {
- if (_waitingForFull)
- {
- return CalculateFullState(out curState, out nextState, targetBufferSize);
- }
- else // this will be how almost all states are calculated
- {
- return CalculateDeltaState(curTick, out curState, out nextState);
- }
- }
-
- private bool CalculateFullState(out GameState curState, out GameState nextState, int targetBufferSize)
- {
- if (_lastFullState != null)
- {
- if (_logging)
- Logger.DebugS("net", $"Resync CurTick to: {_lastFullState.ToSequence}");
-
- var curTick = _timing.CurTick = _lastFullState.ToSequence;
-
- if (_interpEnabled)
- {
- // look for the next state
- var lastTick = new GameTick(curTick.Value - 1);
- var nextTick = new GameTick(curTick.Value + 1);
- nextState = null;
-
- for (var i = 0; i < _stateBuffer.Count; i++)
- {
- var state = _stateBuffer[i];
- if (state.ToSequence == nextTick)
- {
- nextState = state;
- }
- else if (state.ToSequence < lastTick) // remove any old states we find to keep the buffer clean
- {
- _stateBuffer.RemoveSwap(i);
- i--;
- }
- }
-
- // we let the buffer fill up before starting to tick
- if (nextState != null && _stateBuffer.Count >= targetBufferSize)
- {
- curState = _lastFullState;
- _waitingForFull = false;
- return true;
- }
- }
- else if (_stateBuffer.Count >= targetBufferSize)
- {
- curState = _lastFullState;
- nextState = default;
- _waitingForFull = false;
- return true;
- }
- }
-
- if (_logging)
- Logger.DebugS("net", $"Have FullState, filling buffer... ({_stateBuffer.Count}/{targetBufferSize})");
-
- // waiting for full state or buffer to fill
- curState = default;
- nextState = default;
- return false;
- }
-
- private bool CalculateDeltaState(GameTick curTick, out GameState curState, out GameState nextState)
- {
- var lastTick = new GameTick(curTick.Value - 1);
- var nextTick = new GameTick(curTick.Value + 1);
-
- curState = null;
- nextState = null;
-
- for (var i = 0; i < _stateBuffer.Count; i++)
- {
- var state = _stateBuffer[i];
-
- // remember there are no duplicate ToSequence states in the list.
- if (state.ToSequence == curTick)
- {
- curState = state;
- }
- else if (_interpEnabled && state.ToSequence == nextTick)
- {
- nextState = state;
- }
- else if (state.ToSequence < lastTick) // remove any old states we find to keep the buffer clean
- {
- _stateBuffer.RemoveSwap(i);
- i--;
- }
- }
-
- // we found both the states to interpolate between, this should almost always be true.
- if ((_interpEnabled && curState != null) || (!_interpEnabled && curState != null && nextState != null))
- return true;
-
- if (curState == null)
- {
- curState = ExtrapolateState(lastTick, curTick);
- }
-
- if (nextState == null && _interpEnabled)
- {
- nextState = ExtrapolateState(curTick, nextTick);
- }
-
- return true;
- }
-
- ///
- /// Generates a completely fake GameState.
- ///
- private static GameState ExtrapolateState(GameTick fromSequence, GameTick toSequence)
- {
- var state = new GameState(fromSequence, toSequence, null, null, null, null);
- state.Extrapolated = true;
- return state;
+ ApplyGameState(curState, nextState);
}
private void AckGameState(GameTick sequence)
diff --git a/Robust.Client/GameStates/GameStateProcessor.cs b/Robust.Client/GameStates/GameStateProcessor.cs
new file mode 100644
index 000000000..30e98abeb
--- /dev/null
+++ b/Robust.Client/GameStates/GameStateProcessor.cs
@@ -0,0 +1,268 @@
+using System.Collections.Generic;
+using Robust.Shared.GameStates;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.Log;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Robust.Client.GameStates
+{
+ ///
+ internal class GameStateProcessor : IGameStateProcessor
+ {
+ private readonly IGameTiming _timing;
+
+ private readonly List _stateBuffer = new List();
+ private GameState _lastFullState;
+ private bool _waitingForFull = true;
+ private int _interpRatio;
+ private GameTick _lastProcessedRealState;
+
+ ///
+ public int MinBufferSize => Interpolation ? 3 : 2;
+
+ ///
+ public int TargetBufferSize => MinBufferSize + InterpRatio;
+
+ ///
+ public int CurrentBufferSize => _stateBuffer.Count;
+
+ ///
+ public bool Interpolation { get; set; }
+
+ ///
+ public int InterpRatio
+ {
+ get => _interpRatio;
+ set => _interpRatio = value < 0 ? 0 : value;
+ }
+
+ ///
+ public bool Extrapolation { get; set; }
+
+ ///
+ public bool Logging { get; set; }
+
+ ///
+ /// Constructs a new instance of .
+ ///
+ /// Timing information of the current state.
+ public GameStateProcessor(IGameTiming timing)
+ {
+ _timing = timing;
+ }
+
+ ///
+ public void AddNewState(GameState state, int stateSize)
+ {
+ // any state from tick 0 is a full state, and needs to be handled different
+ if (state.FromSequence == GameTick.Zero)
+ {
+ // this is a newer full state, so discard the older one.
+ if (_lastFullState == null || (_lastFullState != null && _lastFullState.ToSequence < state.ToSequence))
+ {
+ _lastFullState = state;
+
+ if (Logging)
+ Logger.InfoS("net", $"Received Full GameState: to={state.ToSequence}, sz={stateSize}");
+
+ return;
+ }
+ }
+
+ // NOTE: DispatchTick will be modifying CurTick, this is NOT thread safe.
+ var lastTick = new GameTick(_timing.CurTick.Value - 1);
+
+ if (state.ToSequence <= lastTick && !_waitingForFull) // CurTick isn't set properly when WaitingForFull
+ {
+ if (Logging)
+ Logger.DebugS("net.state", $"Received Old GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={stateSize}, buf={_stateBuffer.Count}");
+
+ return;
+ }
+
+ // lets check for a duplicate state now.
+ for (var i = 0; i < _stateBuffer.Count; i++)
+ {
+ var iState = _stateBuffer[i];
+
+ if (state.ToSequence != iState.ToSequence)
+ continue;
+
+ if (Logging)
+ Logger.DebugS("net.state", $"Received Dupe GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={stateSize}, buf={_stateBuffer.Count}");
+
+ return;
+ }
+
+ // this is a good state that we will be using.
+ _stateBuffer.Add(state);
+
+ if (Logging)
+ Logger.DebugS("net.state", $"Received New GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={stateSize}, buf={_stateBuffer.Count}");
+ }
+
+ ///
+ public bool ProcessTickStates(GameTick curTick, out GameState curState, out GameState nextState)
+ {
+ bool applyNextState;
+ if (_waitingForFull)
+ {
+ applyNextState = CalculateFullState(out curState, out nextState, TargetBufferSize);
+ }
+ else // this will be how almost all states are calculated
+ {
+ applyNextState = CalculateDeltaState(curTick, out curState, out nextState);
+ }
+
+ if (applyNextState && !curState.Extrapolated)
+ _lastProcessedRealState = curState.ToSequence;
+
+ if (!_waitingForFull)
+ {
+ if (!applyNextState)
+ _timing.CurTick = _lastProcessedRealState;
+
+ // This will slightly speed up or slow down the client tickrate based on the contents of the buffer.
+ // CalcNextState should have just cleaned out any old states, so the buffer contains [t-1(last), t+0(cur), t+1(next), t+2, t+3, ..., t+n]
+ // we can use this info to properly time our tickrate so it does not run fast or slow compared to the server.
+ _timing.TickTimingAdjustment = (_stateBuffer.Count - (float)TargetBufferSize) * 0.10f;
+ }
+ else
+ {
+ _timing.TickTimingAdjustment = 0f;
+ }
+
+ if (Logging && applyNextState)
+ Logger.DebugS("net.state", $"Applying State: ext={curState.Extrapolated}, cTick={_timing.CurTick}, fSeq={curState.FromSequence}, tSeq={curState.ToSequence}, buf={_stateBuffer.Count}");
+
+ return applyNextState;
+ }
+
+ private bool CalculateFullState(out GameState curState, out GameState nextState, int targetBufferSize)
+ {
+ if (_lastFullState != null)
+ {
+ if (Logging)
+ Logger.DebugS("net", $"Resync CurTick to: {_lastFullState.ToSequence}");
+
+ var curTick = _timing.CurTick = _lastFullState.ToSequence;
+
+ if (Interpolation)
+ {
+ // look for the next state
+ var lastTick = new GameTick(curTick.Value - 1);
+ var nextTick = new GameTick(curTick.Value + 1);
+ nextState = null;
+
+ for (var i = 0; i < _stateBuffer.Count; i++)
+ {
+ var state = _stateBuffer[i];
+ if (state.ToSequence == nextTick)
+ {
+ nextState = state;
+ }
+ else if (state.ToSequence < lastTick) // remove any old states we find to keep the buffer clean
+ {
+ _stateBuffer.RemoveSwap(i);
+ i--;
+ }
+ }
+
+ // we let the buffer fill up before starting to tick
+ if (nextState != null && _stateBuffer.Count >= targetBufferSize)
+ {
+ curState = _lastFullState;
+ _waitingForFull = false;
+ return true;
+ }
+ }
+ else if (_stateBuffer.Count >= targetBufferSize)
+ {
+ curState = _lastFullState;
+ nextState = default;
+ _waitingForFull = false;
+ return true;
+ }
+ }
+
+ if (Logging)
+ Logger.DebugS("net", $"Have FullState, filling buffer... ({_stateBuffer.Count}/{targetBufferSize})");
+
+ // waiting for full state or buffer to fill
+ curState = default;
+ nextState = default;
+ return false;
+ }
+
+ private bool CalculateDeltaState(GameTick curTick, out GameState curState, out GameState nextState)
+ {
+ var lastTick = new GameTick(curTick.Value - 1);
+ var nextTick = new GameTick(curTick.Value + 1);
+
+ curState = null;
+ nextState = null;
+
+ for (var i = 0; i < _stateBuffer.Count; i++)
+ {
+ var state = _stateBuffer[i];
+
+ // remember there are no duplicate ToSequence states in the list.
+ if (state.ToSequence == curTick)
+ {
+ curState = state;
+ }
+ else if (Interpolation && state.ToSequence == nextTick)
+ {
+ nextState = state;
+ }
+ else if (state.ToSequence < lastTick) // remove any old states we find to keep the buffer clean
+ {
+ _stateBuffer.RemoveSwap(i);
+ i--;
+ }
+ }
+
+ // we won't extrapolate, and curState was not found.
+ if (!Extrapolation && curState == null)
+ return false;
+
+ // we found both the states to interpolate between, this should almost always be true.
+ if (Interpolation && curState != null)
+ return true;
+
+ if (!Interpolation && curState != null && nextState != null)
+ return true;
+
+ if (curState == null)
+ {
+ curState = ExtrapolateState(lastTick, curTick);
+ }
+
+ if (nextState == null && Interpolation)
+ {
+ nextState = ExtrapolateState(curTick, nextTick);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Generates a completely fake GameState.
+ ///
+ private static GameState ExtrapolateState(GameTick fromSequence, GameTick toSequence)
+ {
+ var state = new GameState(fromSequence, toSequence, null, null, null, null);
+ state.Extrapolated = true;
+ return state;
+ }
+
+ ///
+ public void Reset()
+ {
+ _stateBuffer.Clear();
+ _lastFullState = null;
+ _waitingForFull = true;
+ }
+ }
+}
diff --git a/Robust.Client/GameStates/IGameStateProcessor.cs b/Robust.Client/GameStates/IGameStateProcessor.cs
new file mode 100644
index 000000000..30275d496
--- /dev/null
+++ b/Robust.Client/GameStates/IGameStateProcessor.cs
@@ -0,0 +1,76 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Timing;
+
+namespace Robust.Client.GameStates
+{
+ ///
+ /// Holds a collection of game states and calculates which ones to apply at a given game tick.
+ ///
+ internal interface IGameStateProcessor
+ {
+ ///
+ /// Minimum number of states needed in the buffer for everything to work.
+ ///
+ ///
+ /// With interpolation enabled minimum is 3 states in buffer for the system to work (last, cur, next).
+ /// Without interpolation enabled minimum is 2 states in buffer for the system to work (last, cur).
+ ///
+ int MinBufferSize { get; }
+
+ ///
+ /// The number of states the system is trying to keep in the buffer. This will always
+ /// be greater or equal to .
+ ///
+ int TargetBufferSize { get; }
+
+ ///
+ /// Number of game states currently in the state buffer.
+ ///
+ int CurrentBufferSize { get; }
+
+ ///
+ /// Is frame interpolation turned on?
+ ///
+ bool Interpolation { get; set; }
+
+ ///
+ /// The target number of states to keep in the buffer for network smoothing.
+ ///
+ ///
+ /// For Lan, set this to 0. For Excellent net conditions, set this to 1. For normal network conditions,
+ /// set this to 2. For worse conditions, set it higher.
+ ///
+ int InterpRatio { get; set; }
+
+ ///
+ /// If the client clock runs ahead of the server and the buffer gets emptied, should fake extrapolated states be generated?
+ ///
+ bool Extrapolation { get; set; }
+
+ ///
+ /// Is debug logging enabled? This will dump debug info about every state to the log.
+ ///
+ bool Logging { get; set; }
+
+ ///
+ /// Adds a new state into the processor. These are usually from networking or replays.
+ ///
+ /// Newly received state.
+ /// Optionally provide the size in bytes of this new state. This is strictly for debug logging.
+ void AddNewState(GameState state, int stateSize);
+
+ ///
+ /// Calculates the current and next state to apply for a given game tick.
+ ///
+ /// Tick to get the states for.
+ /// Current state for the given tick. This can be null.
+ /// Current state for tick + 1. This can be null.
+ /// Was the function able to correctly calculate the states for the given tick?
+ bool ProcessTickStates(GameTick curTick, out GameState curState, out GameState nextState);
+
+ ///
+ /// Resets the processor back to its initial state.
+ ///
+ void Reset();
+ }
+}
diff --git a/Robust.Client/Robust.Client.csproj b/Robust.Client/Robust.Client.csproj
index 2ea6fe884..1815994ab 100644
--- a/Robust.Client/Robust.Client.csproj
+++ b/Robust.Client/Robust.Client.csproj
@@ -140,6 +140,8 @@
+
+
diff --git a/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs b/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs
new file mode 100644
index 000000000..ba7324864
--- /dev/null
+++ b/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs
@@ -0,0 +1,188 @@
+using Moq;
+using NUnit.Framework;
+using Robust.Client.GameStates;
+using Robust.Shared.GameStates;
+using Robust.Shared.Interfaces.Timing;
+using Robust.Shared.Timing;
+
+namespace Robust.UnitTesting.Client.GameStates
+{
+ [TestFixture, Parallelizable, TestOf(typeof(GameStateProcessor))]
+ class GameStateProcessor_Tests
+ {
+ [Test]
+ public void FillBufferBlocksProcessing()
+ {
+ var timingMock = new Mock();
+ timingMock.SetupProperty(p => p.CurTick);
+
+ var timing = timingMock.Object;
+ var processor = new GameStateProcessor(timing);
+
+ processor.AddNewState(GameStateFactory(0, 1), 0);
+ processor.AddNewState(GameStateFactory(1, 2), 0); // buffer is at 2/3, so processing should be blocked
+
+ // calculate states for first tick
+ timing.CurTick = new GameTick(3);
+ var result = processor.ProcessTickStates(new GameTick(1), out _, out _);
+
+ Assert.That(result, Is.False);
+ Assert.That(timing.CurTick.Value, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void FillBufferAndCalculateFirstState()
+ {
+ var timingMock = new Mock();
+ timingMock.SetupProperty(p => p.CurTick);
+
+ var timing = timingMock.Object;
+ var processor = new GameStateProcessor(timing);
+
+ processor.AddNewState(GameStateFactory(0, 1), 0);
+ processor.AddNewState(GameStateFactory(1, 2), 0);
+ processor.AddNewState(GameStateFactory(2, 3), 0); // buffer is now full, otherwise cannot calculate states.
+
+ // calculate states for first tick
+ timing.CurTick = new GameTick(1);
+ var result = processor.ProcessTickStates(new GameTick(1), out var curState, out var nextState);
+
+ Assert.That(result, Is.True);
+ Assert.That(curState, Is.Not.Null);
+ Assert.That(curState.Extrapolated, Is.False);
+ Assert.That(curState.ToSequence.Value, Is.EqualTo(1));
+ Assert.That(nextState, Is.Null);
+ }
+
+ ///
+ /// When a full state is in the queue (fromSequence = 0), it will modify CurTick to the states' toSequence,
+ /// then return the state as curState.
+ ///
+ [Test]
+ public void FullStateResyncsCurTick()
+ {
+ var timingMock = new Mock();
+ timingMock.SetupProperty(p => p.CurTick);
+
+ var timing = timingMock.Object;
+ var processor = new GameStateProcessor(timing);
+
+ processor.AddNewState(GameStateFactory(0, 1), 0);
+ processor.AddNewState(GameStateFactory(1, 2), 0);
+ processor.AddNewState(GameStateFactory(2, 3), 0); // buffer is now full, otherwise cannot calculate states.
+
+ // calculate states for first tick
+ timing.CurTick = new GameTick(3);
+ processor.ProcessTickStates(timing.CurTick, out _, out _);
+
+ Assert.That(timing.CurTick.Value, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void StatesReceivedPastCurTickAreDropped()
+ {
+ var (timing, processor) = SetupProcessorFactory();
+
+ processor.Extrapolation = false;
+
+ // a few moments later...
+ timing.CurTick = new GameTick(5); // current clock is ahead of server
+ processor.AddNewState(GameStateFactory(3, 4), 0); // received a late state
+ var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
+
+ Assert.That(result, Is.False);
+ }
+
+ ///
+ /// The server fell behind the client, so the client clock is now ahead of the incoming states.
+ /// Without extrapolation, processing blocks.
+ ///
+ [Test]
+ public void ServerLagsWithoutExtrapolation()
+ {
+ var (timing, processor) = SetupProcessorFactory();
+
+ processor.Extrapolation = false;
+
+ // a few moments later...
+ timing.CurTick = new GameTick(5); // current clock is ahead of server
+ var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
+
+ Assert.That(result, Is.False);
+ }
+
+ ///
+ /// When processing is blocked because the client is ahead of the server, reset CurTick to the last
+ /// received state.
+ ///
+ [Test]
+ public void ServerLagsWithoutExtrapolationSetsCurTick()
+ {
+ var (timing, processor) = SetupProcessorFactory();
+
+ processor.Extrapolation = false;
+
+ // a few moments later...
+ timing.CurTick = new GameTick(4); // current clock is ahead of server (server=1, client=5)
+ var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
+
+ Assert.That(result, Is.False);
+ Assert.That(timing.CurTick.Value, Is.EqualTo(1));
+ }
+
+ ///
+ /// The server fell behind the client, so the client clock is now ahead of the incoming states.
+ /// With extrapolation, processing returns a fake extrapolated state for the current tick.
+ ///
+ [Test]
+ public void ServerLagsWithExtrapolation()
+ {
+ var (timing, processor) = SetupProcessorFactory();
+
+ processor.Extrapolation = true;
+
+ // a few moments later...
+ timing.CurTick = new GameTick(5); // current clock is ahead of server
+
+ var result = processor.ProcessTickStates(timing.CurTick, out var curState, out var nextState);
+
+ Assert.That(result, Is.True);
+ Assert.That(curState, Is.Not.Null);
+ Assert.That(curState.Extrapolated, Is.True);
+ Assert.That(curState.ToSequence.Value, Is.EqualTo(5));
+ Assert.That(nextState, Is.Null);
+ }
+
+ ///
+ /// Creates a new empty GameState with the given to and from properties.
+ ///
+ private static GameState GameStateFactory(uint from, uint to)
+ {
+ return new GameState(new GameTick(from), new GameTick(to), null, null, null, null);
+ }
+
+ ///
+ /// Creates a new GameTiming and GameStateProcessor, fills the processor with enough states, and calculate the first tick.
+ /// CurTick = 1, states 1 - 3 are in the buffer.
+ ///
+ private static (IGameTiming timing, GameStateProcessor processor) SetupProcessorFactory()
+ {
+ var timingMock = new Mock();
+ timingMock.SetupProperty(p => p.CurTick);
+ timingMock.SetupProperty(p => p.TickTimingAdjustment);
+
+ var timing = timingMock.Object;
+ var processor = new GameStateProcessor(timing);
+
+ processor.AddNewState(GameStateFactory(0, 1), 0);
+ processor.AddNewState(GameStateFactory(1, 2), 0);
+ processor.AddNewState(GameStateFactory(2, 3), 0); // buffer is now full, otherwise cannot calculate states.
+
+ // calculate states for first tick
+ timing.CurTick = new GameTick(1);
+ processor.ProcessTickStates(timing.CurTick, out _, out _);
+
+ return (timing, processor);
+ }
+ }
+}
diff --git a/Robust.UnitTesting/Robust.UnitTesting.csproj b/Robust.UnitTesting/Robust.UnitTesting.csproj
index 8085febac..505ced828 100644
--- a/Robust.UnitTesting/Robust.UnitTesting.csproj
+++ b/Robust.UnitTesting/Robust.UnitTesting.csproj
@@ -92,6 +92,7 @@
+