From af6cac14d646112c1cff8a55a7528cead0c8d858 Mon Sep 17 00:00:00 2001 From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Date: Mon, 27 Jan 2025 21:23:23 +1100 Subject: [PATCH] Add CollisionPredictionTest (#5493) Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com> Co-authored-by: metalgearsloth --- RELEASE-NOTES.md | 2 +- .../GameStates/ClientGameStateManager.cs | 6 +- .../Physics/PhysicsSystem.Predict.cs | 2 +- .../SharedTransformSystem.Component.cs | 4 +- .../Physics/Systems/SharedBroadphaseSystem.cs | 26 +- .../Systems/SharedPhysicsSystem.Queries.cs | 22 +- .../Physics/Systems/SharedPhysicsSystem.cs | 22 + .../Shared/Physics/CollisionPredictionTest.cs | 515 ++++++++++++++++++ 8 files changed, 571 insertions(+), 28 deletions(-) create mode 100644 Robust.UnitTesting/Shared/Physics/CollisionPredictionTest.cs diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index f1ca76a87d8..cf41f3bd236 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -43,7 +43,7 @@ END TEMPLATE--> ### Bugfixes -*None yet* +* Fixed `RaisePredictiveEvent()` not properly re-raising events during prediction for event handlers that did not take an `EntitySessionEventArgs` argument. ### Other diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs index bb30ea4ddc5..36c0fed3740 100644 --- a/Robust.Client/GameStates/ClientGameStateManager.cs +++ b/Robust.Client/GameStates/ClientGameStateManager.cs @@ -42,7 +42,7 @@ public sealed class ClientGameStateManager : IClientGameStateManager private uint _nextInputCmdSeq = 1; private readonly Queue _pendingInputs = new(); - private readonly Queue<(uint sequence, GameTick sourceTick, EntityEventArgs msg, object sessionMsg)> + private readonly Queue<(uint sequence, GameTick sourceTick, object msg, object sessionMsg)> _pendingSystemMessages = new(); @@ -504,9 +504,7 @@ public void PredictTicks(GameTick predictionTarget) while (hasPendingMessage && pendingMessagesEnumerator.Current.sourceTick <= _timing.CurTick) { - var msg = pendingMessagesEnumerator.Current.msg; - - _entities.EventBus.RaiseEvent(EventSource.Local, msg); + _entities.EventBus.RaiseEvent(EventSource.Local, pendingMessagesEnumerator.Current.msg); _entities.EventBus.RaiseEvent(EventSource.Local, pendingMessagesEnumerator.Current.sessionMsg); hasPendingMessage = pendingMessagesEnumerator.MoveNext(); } diff --git a/Robust.Client/Physics/PhysicsSystem.Predict.cs b/Robust.Client/Physics/PhysicsSystem.Predict.cs index c21ea92d38d..cd5ba983095 100644 --- a/Robust.Client/Physics/PhysicsSystem.Predict.cs +++ b/Robust.Client/Physics/PhysicsSystem.Predict.cs @@ -93,7 +93,7 @@ internal void ResetContacts() var maps = new HashSet(); var enumerator = AllEntityQuery(); - while (enumerator.MoveNext(out var _, out var physics, out var xform)) + while (enumerator.MoveNext(out _, out var physics, out var xform)) { DebugTools.Assert(physics.Predict); diff --git a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs index 1323c693edd..d9a6f0a8311 100644 --- a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs +++ b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs @@ -899,11 +899,11 @@ public void SetMapCoordinates(Entity entity, MapCoordinates _mapManager.TryFindGridAt(mapUid, coordinates.Position, out var targetGrid, out _)) { var invWorldMatrix = GetInvWorldMatrix(targetGrid); - SetCoordinates(entity, new EntityCoordinates(targetGrid, Vector2.Transform(coordinates.Position, invWorldMatrix))); + SetCoordinates((entity.Owner, entity.Comp, MetaData(entity.Owner)), new EntityCoordinates(targetGrid, Vector2.Transform(coordinates.Position, invWorldMatrix))); } else { - SetCoordinates(entity, new EntityCoordinates(mapUid, coordinates.Position)); + SetCoordinates((entity.Owner, entity.Comp, MetaData(entity.Owner)), new EntityCoordinates(mapUid, coordinates.Position)); } } diff --git a/Robust.Shared/Physics/Systems/SharedBroadphaseSystem.cs b/Robust.Shared/Physics/Systems/SharedBroadphaseSystem.cs index 726509fdaff..daa644e1eee 100644 --- a/Robust.Shared/Physics/Systems/SharedBroadphaseSystem.cs +++ b/Robust.Shared/Physics/Systems/SharedBroadphaseSystem.cs @@ -1,5 +1,4 @@ using System; -using System.Buffers; using System.Collections.Generic; using System.Numerics; using System.Threading.Tasks; @@ -8,7 +7,6 @@ using Robust.Shared.Configuration; using Robust.Shared.GameObjects; using Robust.Shared.IoC; -using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Maths; @@ -413,24 +411,34 @@ private void QueryBroadphase(IBroadPhase broadPhase, (List, Fixtur }, aabb, true); } + [Obsolete("Use Entity variant")] public void RegenerateContacts(EntityUid uid, PhysicsComponent body, FixturesComponent? fixtures = null, TransformComponent? xform = null) { - _physicsSystem.DestroyContacts(body); - if (!Resolve(uid, ref xform, ref fixtures)) + RegenerateContacts((uid, body, fixtures, xform)); + } + + public void RegenerateContacts(Entity entity) + { + if (!Resolve(entity.Owner, ref entity.Comp1)) return; - if (xform.MapUid == null) + _physicsSystem.DestroyContacts(entity.Comp1); + + if (!Resolve(entity.Owner, ref entity.Comp2 , ref entity.Comp3)) return; - if (!_xformQuery.TryGetComponent(xform.Broadphase?.Uid, out var broadphase)) + if (entity.Comp3.MapUid == null) + return; + + if (!_xformQuery.TryGetComponent(entity.Comp3.Broadphase?.Uid, out var broadphase)) return; - _physicsSystem.SetAwake((uid, body), true); + _physicsSystem.SetAwake(entity!, true); var matrix = _transform.GetWorldMatrix(broadphase); - foreach (var fixture in fixtures.Fixtures.Values) + foreach (var fixture in entity.Comp2.Fixtures.Values) { - TouchProxies(xform.MapUid.Value, matrix, fixture); + TouchProxies(entity.Comp3.MapUid.Value, matrix, fixture); } } diff --git a/Robust.Shared/Physics/Systems/SharedPhysicsSystem.Queries.cs b/Robust.Shared/Physics/Systems/SharedPhysicsSystem.Queries.cs index 7beb786c484..4e1eb5e651f 100644 --- a/Robust.Shared/Physics/Systems/SharedPhysicsSystem.Queries.cs +++ b/Robust.Shared/Physics/Systems/SharedPhysicsSystem.Queries.cs @@ -202,27 +202,27 @@ public IEnumerable> GetCollidingEntities(MapId mapId, i return bodies; } - public HashSet GetContactingEntities(EntityUid uid, PhysicsComponent? body = null, bool approximate = false) + public void GetContactingEntities(Entity ent, HashSet contacting, bool approximate = false) { - // HashSet to ensure that we only return each entity once, instead of once per colliding fixture. - var result = new HashSet(); + if (!Resolve(ent.Owner, ref ent.Comp)) + return; - if (!Resolve(uid, ref body)) - return result; - - var node = body.Contacts.First; + var node = ent.Comp.Contacts.First; while (node != null) { var contact = node.Value; node = node.Next; - if (!approximate && !contact.IsTouching) - continue; - - result.Add(uid == contact.EntityA ? contact.EntityB : contact.EntityA); + if (approximate || contact.IsTouching) + contacting.Add(ent.Owner == contact.EntityA ? contact.EntityB : contact.EntityA); } + } + public HashSet GetContactingEntities(EntityUid uid, PhysicsComponent? body = null, bool approximate = false) + { + var result = new HashSet(); + GetContactingEntities((uid, body), result, approximate); return result; } diff --git a/Robust.Shared/Physics/Systems/SharedPhysicsSystem.cs b/Robust.Shared/Physics/Systems/SharedPhysicsSystem.cs index 4aa69d3d67f..6a12eb99b2c 100644 --- a/Robust.Shared/Physics/Systems/SharedPhysicsSystem.cs +++ b/Robust.Shared/Physics/Systems/SharedPhysicsSystem.cs @@ -327,6 +327,28 @@ protected void SimulateWorld(float deltaTime, bool prediction) RaiseLocalEvent(ref updateMapBeforeSolve); } + // TODO PHYSICS Fix Collision Mispredicts + // If a physics update induces a position update that brings fixtures into contact, the collision starts in the NEXT tick, + // as positions are updated after CollideContacts() gets called. + // + // If a player input induces a position update that brings fixtures into contact, the collision happens on the SAME tick, + // as inputs are handled before system updates. + // + // When applying a server's game state with new positions, the client won't know what caused the positions to update, + // and thus can't know whether the collision already occurred (i.e., whether its effects are already contained within the + // game state currently being applied), or whether it should start on the next tick and needs to predict the start of + // the collision. + // + // Currently the client assumes that any position updates happened due to physics steps. I.e., positions are reset, then + // contacts are reset via ResetContacts(), then positions are updated using the new game state. Alternatively, we could + // call ResetContacts() AFTER applying the server state, which would correspond to assuming that the collisions have + // already "started" in the state, and we don't want to re-raise the events. + // + // Currently there is no way to avoid mispredicts from happening. E.g., a simple collision-counter component will always + // either mispredict on physics induced position changes, or on player/input induced updates. The easiest way I can think + // of to fix this would be to always call `CollideContacts` again at the very end of a physics update. + // But that might be unnecessarily expensive for what are hopefully only infrequent mispredicts. + CollideContacts(); var enumerator = AllEntityQuery(); diff --git a/Robust.UnitTesting/Shared/Physics/CollisionPredictionTest.cs b/Robust.UnitTesting/Shared/Physics/CollisionPredictionTest.cs new file mode 100644 index 00000000000..5a78cfce367 --- /dev/null +++ b/Robust.UnitTesting/Shared/Physics/CollisionPredictionTest.cs @@ -0,0 +1,515 @@ +using System; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Client.Physics; +using Robust.Shared; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Network; +using Robust.Shared.Physics.Collision.Shapes; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Dynamics; +using Robust.Shared.Physics.Events; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Serialization; +using Robust.Shared.Timing; + +namespace Robust.UnitTesting.Shared.Physics; + +/// +/// This test is meant to check that collision start & stop events are raised correctly by the client. +/// The expectation is that start & stop events are only raised if the client predicts that two entities will move into +/// contact. They do not get raised as a result of applying component states received from the server. +/// I.e., the assumption is that if a collision results in changes to data on a component, then that data will already +/// have been sent to clients in the component's state, so we don't want to "double count" collisions. +/// +public sealed class CollisionPredictionTest : RobustIntegrationTest +{ + private static readonly string Prototypes = @" +- type: entity + id: CollisionTest1 + components: + - type: CollisionPredictionTest + - type: Physics + bodyType: Dynamic + sleepingAllowed: false + +- type: entity + id: CollisionTest2 + components: + - type: Physics + bodyType: Dynamic + sleepingAllowed: false +"; + + [Test] + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public async Task TestCollisionPrediction(bool hard1, bool hard2) + { + var serverOpts = new ServerIntegrationOptions { Pool = false, ExtraPrototypes = Prototypes }; + var clientOpts = new ClientIntegrationOptions { Pool = false, ExtraPrototypes = Prototypes }; + var server = StartServer(serverOpts); + var client = StartClient(clientOpts); + + await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); + var netMan = client.ResolveDependency(); + Assert.DoesNotThrow(() => client.SetConnectTarget(server)); + await server.WaitPost(() => server.CfgMan.SetCVar(CVars.NetPVS, false)); + await client.WaitPost(() => netMan.ClientConnect(null!, 0, null!)); + + var sFix = server.System(); + var sPhys = server.System(); + var sSys = server.System(); + + // Set up entities + EntityUid map = default; + EntityUid sEntity1 = default; + EntityUid sEntity2 = default; + MapCoordinates coords1 = default; + MapCoordinates coords2 = default; + await server.WaitPost(() => + { + var radius = 0.25f; + map = server.System().CreateMap(out var mapId); + coords1 = new(default, mapId); + coords2 = new(Vector2.One, mapId); + sEntity1 = server.EntMan.Spawn("CollisionTest1", coords1); + sEntity2 = server.EntMan.Spawn("CollisionTest2", new MapCoordinates(coords2.Position + new Vector2(0, radius), mapId)); + sFix.CreateFixture(sEntity1, "a", new Fixture(new PhysShapeCircle(radius), 1, 1, hard1)); + sFix.CreateFixture(sEntity2, "a", new Fixture(new PhysShapeCircle(radius), 1, 1, hard2)); + sPhys.SetCanCollide(sEntity1, true); + sPhys.SetCanCollide(sEntity2, true); + sPhys.SetAwake((sEntity1, server.EntMan.GetComponent(sEntity1)), true); + sPhys.SetAwake((sEntity2, server.EntMan.GetComponent(sEntity2)), true); + }); + + for (var i = 0; i < 10; i++) + { + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + await server.WaitPost(() => server.PlayerMan.JoinGame(server.PlayerMan.Sessions.First())); + + for (var i = 0; i < 10; i++) + { + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + // Ensure client & server ticks are synced. + // Client runs 2 tick ahead + { + var targetDelta = 2; + var sTick = (int)server.Timing.CurTick.Value; + var cTick = (int)client.Timing.CurTick.Value; + var delta = cTick - sTick; + + if (delta > targetDelta) + await server.WaitRunTicks(delta - targetDelta); + else if (delta < targetDelta) + await client.WaitRunTicks(targetDelta - delta); + + sTick = (int)server.Timing.CurTick.Value; + cTick = (int)client.Timing.CurTick.Value; + delta = cTick - sTick; + Assert.That(delta, Is.EqualTo(targetDelta)); + } + + var cPhys = client.System(); + var cSys = client.System(); + + void ResetSystem() + { + sSys.CollisionEnded = false; + sSys.CollisionStarted = false; + + cSys.CollisionEnded = false; + cSys.CollisionStarted = false; + } + + async Task Tick() + { + ResetSystem(); + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + var nEntity1 = server.EntMan.GetNetEntity(sEntity1); + var nEntity2 = server.EntMan.GetNetEntity(sEntity2); + + var cEntity1 = client.EntMan.GetEntity(nEntity1); + var cEntity2 = client.EntMan.GetEntity(nEntity2); + + var sComp = server.EntMan.GetComponent(sEntity1); + var cComp = client.EntMan.GetComponent(cEntity1); + + cPhys.UpdateIsPredicted(cEntity1); + + // Initially, the objects are not colliding. + { + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // We now simulate a predictive event that gets raised due to some client-side input, causing the entities to + // move and start colliding. Instead of setting up a proper input / keybind handler, The predictive event will + // just be raised in the system update method, which updates before the physics system does. + { + cSys.Ev = new CollisionTestMoveEvent(nEntity1, coords2); + await Tick(); + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.True); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 })); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 })); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.True); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // Run another tick. Client should reset states, and re-predict the event. + { + await Tick(); + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.True); + Assert.That(cComp.WasTouching, Is.True); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 })); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 })); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.True); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // Next tick the server should raise the event received from the client, which will raise a serve-side + // collide-start event. + { + await Tick(); + Assert.That(sComp.IsTouching, Is.True); + Assert.That(cComp.IsTouching, Is.True); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.WasTouching, Is.True); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 })); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 })); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 })); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 })); + Assert.That(sSys.CollisionStarted, Is.True); + Assert.That(cSys.CollisionStarted, Is.True); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // The client will have received the server-state, but will take some time for it to leave the state buffer. + // In the meantime, the client will keep predicting that the collision will "starts" + for (var i = 0; i < 2; i ++) + { + await Tick(); + Assert.That(sComp.IsTouching, Is.True); + Assert.That(cComp.IsTouching, Is.True); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.WasTouching, Is.True); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 })); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 })); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 })); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 })); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.True); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // Then in the next tick the client should apply the new server state, wherein the contacts were already touching. + // I.e., the contact start event never actually gets raised. + { + await Tick(); + Assert.That(sComp.IsTouching, Is.True); + Assert.That(cComp.IsTouching, Is.True); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.WasTouching, Is.False); // IsTouching gets resets to false before server state is applied + Assert.That(cComp.LastState, Is.True); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 })); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 })); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 })); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 })); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // for the next few ticks, nothing should change + for (var i = 0; i < 10; i ++) + { + await Tick(); + Assert.That(sComp.IsTouching, Is.True); + Assert.That(cComp.IsTouching, Is.True); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.True); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 })); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 })); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 })); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 })); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // Next we move the entity away again, so the contact should stop + { + cSys.Ev = new CollisionTestMoveEvent(nEntity1, coords1); + await Tick(); + Assert.That(sComp.IsTouching, Is.True); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.True); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 })); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 })); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.True); + } + + // Next tick, the client should reset to a state where the entities were touching, and then re-predict the stop-collide events + { + await Tick(); + Assert.That(sComp.IsTouching, Is.True); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.True); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 })); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 })); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.True); + } + + // Next, the server should receive the networked event + { + await Tick(); + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick)); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.True); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.True); + Assert.That(cSys.CollisionEnded, Is.True); + } + + // nothing changes while waiting for the client to apply the new server state + for (var i = 0; i < 2; i ++) + { + await Tick(); + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick)); + Assert.That(cComp.WasTouching, Is.False); + Assert.That(cComp.LastState, Is.True); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.True); + } + + // And then the client should apply the new server state + { + await Tick(); + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick)); + Assert.That(cComp.WasTouching, Is.True); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + + // Nothing should change in the next few ticks + for (var i = 0; i < 10; i ++) + { + await Tick(); + Assert.That(sComp.IsTouching, Is.False); + Assert.That(cComp.IsTouching, Is.False); + Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick)); + Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick)); + Assert.That(cComp.WasTouching, Is.True); + Assert.That(cComp.LastState, Is.False); + Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty); + Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty); + Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty); + Assert.That(sSys.CollisionStarted, Is.False); + Assert.That(cSys.CollisionStarted, Is.False); + Assert.That(sSys.CollisionEnded, Is.False); + Assert.That(cSys.CollisionEnded, Is.False); + } + } +} + +[RegisterComponent, NetworkedComponent] +public sealed partial class CollisionPredictionTestComponent : Component +{ + public bool IsTouching; + public bool WasTouching; + public bool LastState; + public GameTick StartTick; + public GameTick StopTick; + + [Serializable, NetSerializable] + public sealed class State(bool isTouching) : ComponentState + { + public bool IsTouching = isTouching; + } +} + +[Serializable, NetSerializable] +public sealed class CollisionTestMoveEvent(NetEntity ent, MapCoordinates coords) : EntityEventArgs +{ + public NetEntity Ent = ent; + public MapCoordinates Coords = coords; +} + +public sealed class CollisionPredictionTestSystem : EntitySystem +{ + [Dependency] private readonly SharedTransformSystem _xform = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + public bool CollisionStarted; + public bool CollisionEnded; + + public override void Initialize() + { + SubscribeLocalEvent(OnHandleState); + SubscribeLocalEvent(OnGetState); + SubscribeLocalEvent(OnStartCollide); + SubscribeLocalEvent(OnEndCollide); + SubscribeLocalEvent(OnIsPredicted); + SubscribeAllEvent(OnMove); + + // Updates before physics to simulate input events. + // inputs are processed before systems update, but I CBF setting up a proper input / keybinding. + UpdatesBefore.Add(typeof(SharedPhysicsSystem)); + } + + public CollisionTestMoveEvent? Ev; + + public override void Update(float frameTime) + { + if (Ev == null || !_timing.IsFirstTimePredicted) + return; + + RaisePredictiveEvent(Ev); + Ev = null; + } + + private void OnIsPredicted(Entity ent, ref UpdateIsPredictedEvent args) + { + args.IsPredicted = true; + } + + private void OnMove(CollisionTestMoveEvent ev) + { + _xform.SetMapCoordinates(GetEntity(ev.Ent), ev.Coords); + } + + private void OnEndCollide(Entity ent, ref EndCollideEvent args) + { + // TODO PHYSICS Collision Mispredicts + // Currently the client will raise collision start/stop events multiple times for each collision + // If this ever gets fixed, re-add the assert: + // Assert.That(ent.Comp.IsTouching, Is.True); + if (!ent.Comp.IsTouching) + return; + + Assert.That(CollisionEnded, Is.False); + ent.Comp.StopTick = _timing.CurTick; + ent.Comp.IsTouching = false; + CollisionEnded = true; + Dirty(ent); + } + + private void OnStartCollide(Entity ent, ref StartCollideEvent args) + { + // TODO PHYSICS Collision Mispredicts + // Currently the client will raise collision start/stop events multiple times for each collision + // If this ever gets fixed, re-add the assert: + // Assert.That(ent.Comp.IsTouching, Is.False); + if (ent.Comp.IsTouching) + return; + + Assert.That(CollisionStarted, Is.False); + ent.Comp.StartTick = _timing.CurTick; + ent.Comp.IsTouching = true; + CollisionStarted = true; + Dirty(ent); + } + + private void OnGetState(Entity ent, ref ComponentGetState args) + { + args.State = new CollisionPredictionTestComponent.State(ent.Comp.IsTouching); + } + + private void OnHandleState(Entity ent, ref ComponentHandleState args) + { + if (args.Current is not CollisionPredictionTestComponent.State state) + return; + + ent.Comp.WasTouching = ent.Comp.IsTouching; + ent.Comp.LastState = state.IsTouching; + ent.Comp.IsTouching = state.IsTouching; + } +}