From 39eeab14eae9e9c2fdc8640dea17913936129b08 Mon Sep 17 00:00:00 2001 From: Acruid Date: Fri, 3 May 2019 04:26:06 -0700 Subject: [PATCH] Disable Extrapolation (#803) * Split GameState processing logic out of ClientGameStateManager into the new GameStateProcessor class. * Adds a test fixture for GameStateProcessor with a basic use test. * More unit tests and added Extrapolated property to GameStateProcessor. --- .../GameStates/ClientGameStateManager.cs | 241 ++-------------- .../GameStates/GameStateProcessor.cs | 268 ++++++++++++++++++ .../GameStates/IGameStateProcessor.cs | 76 +++++ Robust.Client/Robust.Client.csproj | 2 + .../GameStates/GameStateProcessor_Tests.cs | 188 ++++++++++++ Robust.UnitTesting/Robust.UnitTesting.csproj | 1 + 6 files changed, 556 insertions(+), 220 deletions(-) create mode 100644 Robust.Client/GameStates/GameStateProcessor.cs create mode 100644 Robust.Client/GameStates/IGameStateProcessor.cs create mode 100644 Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs 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 @@ +