using System.Collections.Immutable; using System.Numerics; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests { private const uint Cell = 0xA9B40021u; private const uint SetupId = 0x02000001u; [Fact] public void PreparationOwnsPrivateBodyAndMutatesNoCanonicalOrSharedState() { using var fixture = new Fixture(); uint fullCell = fixture.Record.FullCellId; ulong spatial = fixture.Record.SpatialAuthorityVersion; ulong physicsEpoch = fixture.Record.PhysicsOwnershipEpoch; ulong clockEpoch = fixture.Record.ObjectClockEpoch; RuntimePhysicsOwnershipSnapshot physics = fixture.Lifetime.Physics .CaptureOwnership(); RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); Assert.Null(fixture.Record.PhysicsBody); Assert.Null(fixture.Movement.Controller); Assert.Equal(fullCell, fixture.Record.FullCellId); Assert.Equal(spatial, fixture.Record.SpatialAuthorityVersion); Assert.Equal(physicsEpoch, fixture.Record.PhysicsOwnershipEpoch); Assert.Equal(clockEpoch, fixture.Record.ObjectClockEpoch); Assert.Null(fixture.Record.PhysicsHost); Assert.Equal(physics.SpatialRootCount, fixture.Lifetime.Physics.CaptureOwnership().SpatialRootCount); Assert.Equal(physics.RetainedShadowRegistrationCount, fixture.Lifetime.Physics.CaptureOwnership() .RetainedShadowRegistrationCount); Assert.Equal(1, fixture.Owner.CaptureOwnership().CandidateCount); Assert.True(token.IsValid); } [Fact] public void CommitOwnsExactDormantBodyAndControllerWithoutWorldEdges() { using var fixture = new Fixture(); RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); Assert.True(fixture.Owner.TryCaptureCandidateSnapshot( token, out RuntimeLocalPlayerPhysicsCandidateSnapshot prepared)); uint fullCell = fixture.Record.FullCellId; ulong spatial = fixture.Record.SpatialAuthorityVersion; ulong bodyEpoch = fixture.Record.PhysicsOwnershipEpoch; ulong controllerEpoch = fixture.Movement.ControllerOwnershipEpoch; double clockPending = fixture.Record.ObjectClock.PendingSeconds; bool clockActive = fixture.Record.ObjectClock.IsActive; object? currentCell = fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell; RuntimeLocalPlayerPhysicsPublicationStatus status = fixture.Owner .Commit(token); Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, status); PlayerMovementController candidate = Assert.IsType( fixture.Movement.Controller); PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); Assert.Same(body, fixture.Record.PhysicsBody); Assert.Same(candidate, fixture.Movement.Controller); Assert.True(candidate.OwnsPhysicsBody(body)); Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch); Assert.Equal(controllerEpoch + 1UL, fixture.Movement.ControllerOwnershipEpoch); Assert.Equal(fullCell, fixture.Record.FullCellId); Assert.Equal(spatial, fixture.Record.SpatialAuthorityVersion); Assert.Same(currentCell, fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell); Assert.Equal(clockPending, fixture.Record.ObjectClock.PendingSeconds); Assert.Equal(clockActive, fixture.Record.ObjectClock.IsActive); Assert.Equal(prepared.Position, body.Position); Assert.Equal(prepared.Orientation, body.Orientation); Assert.Equal(prepared.CellId, body.CellPosition.ObjCellId); Assert.Equal(prepared.CellLocalPosition, body.CellPosition.Frame.Origin); Assert.Equal(prepared.State, body.State); Assert.Equal(prepared.TransientState, body.TransientState); Assert.Equal(prepared.InWorld, body.InWorld); Assert.False(body.InWorld); Assert.False((body.TransientState & TransientStateFlags.Active) != 0); Assert.Null(fixture.Record.PhysicsHost); Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() .SpatialRootCount); Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership() .RetainedShadowRegistrationCount); Assert.True(fixture.Lifetime.Physics.SetPosition .IsExactPreparedPlacementCurrent( fixture.Record, fixture.Placement, fixture.Command)); Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); AssertNotLive(candidate); Assert.Equal(prepared.Position, body.Position); Assert.Equal(prepared.Orientation, body.Orientation); Assert.Equal(prepared.State, body.State); Assert.Equal(prepared.TransientState, body.TransientState); Assert.Equal(clockPending, fixture.Record.ObjectClock.PendingSeconds); Assert.Equal(clockActive, fixture.Record.ObjectClock.IsActive); Assert.Same(currentCell, fixture.Lifetime.Physics.DataCache.CellGraph.CurrCell); Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken, fixture.Owner.Commit(token)); } [Theory] [InlineData(PublicationInvalidation.SetPositionReplacement)] [InlineData(PublicationInvalidation.Vector)] [InlineData(PublicationInvalidation.FinalState)] [InlineData(PublicationInvalidation.ObjectDescription)] [InlineData(PublicationInvalidation.Create)] [InlineData(PublicationInvalidation.Remote)] [InlineData(PublicationInvalidation.Projectile)] [InlineData(PublicationInvalidation.Body)] [InlineData(PublicationInvalidation.Clock)] [InlineData(PublicationInvalidation.Controller)] [InlineData(PublicationInvalidation.CancelPlacement)] public void EveryNestedAuthorityReplacementDiscardsCandidateWithoutRollback( PublicationInvalidation invalidation) { using var fixture = new Fixture(); RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); PhysicsBody? replacementBody = null; PlayerMovementController? replacementController = null; switch (invalidation) { case PublicationInvalidation.SetPositionReplacement: Assert.True(fixture.Lifetime.Physics.SetPosition .BeginAuthoredPlacement( fixture.Record, fixture.Record.PositionAuthorityVersion, RuntimeSetPositionOperationKind.LocalAuthoritative) .IsValid); break; case PublicationInvalidation.Vector: fixture.Lifetime.Entities.AdvanceVectorAuthority(fixture.Record); break; case PublicationInvalidation.FinalState: fixture.Lifetime.Entities.SetFinalPhysicsState( fixture.Record, fixture.Record.FinalPhysicsState | PhysicsStateFlags.Frozen); break; case PublicationInvalidation.ObjectDescription: fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record); break; case PublicationInvalidation.Create: fixture.Lifetime.Entities.AdvanceCreateAuthority(fixture.Record); break; case PublicationInvalidation.Remote: fixture.Lifetime.Entities.SetRemoteMotion( fixture.Record, new RemoteMotion()); break; case PublicationInvalidation.Projectile: fixture.Lifetime.Entities.SetProjectile( fixture.Record, new RuntimeProjectile( new PhysicsBody(), new ProjectileCollisionSphere(Vector3.Zero, 0.1f))); break; case PublicationInvalidation.Body: replacementBody = new PhysicsBody(); fixture.Lifetime.Entities.SetPhysicsBody( fixture.Record, replacementBody); break; case PublicationInvalidation.Clock: fixture.Lifetime.Entities.SuspendObjectClock(fixture.Record); break; case PublicationInvalidation.Controller: replacementController = new PlayerMovementController( new PhysicsEngine()); fixture.Movement.Controller = replacementController; break; case PublicationInvalidation.CancelPlacement: Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( fixture.Record, publishWithdrawal: false)); break; default: throw new ArgumentOutOfRangeException(nameof(invalidation)); } Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, fixture.Owner.Commit(token)); Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); if (replacementBody is not null) Assert.Same(replacementBody, fixture.Record.PhysicsBody); if (replacementController is not null) Assert.Same(replacementController, fixture.Movement.Controller); } [Fact] public void DeleteAndGuidReuseCannotPublishRetiredCandidate() { using var fixture = new Fixture(); RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); uint guid = fixture.Record.ServerGuid; Assert.True(fixture.Lifetime.TryAcceptDelete( new DeleteObject.Parsed(guid, fixture.Record.Incarnation), isLocalPlayer: false, removeRetainedObject: false, out RuntimeEntityDeleteAcceptance acceptance)); fixture.Lifetime.CompleteAcceptedDelete(acceptance); Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); RuntimeEntityRecord replacement = fixture.Lifetime.RegisterEntity( Spawn(guid, incarnation: 2)).Canonical!; Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, fixture.Owner.Commit(token)); Assert.Null(replacement.PhysicsBody); Assert.Null(fixture.Movement.Controller); Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); } [Fact] public void ResetAndDisposeDiscardCandidatesAndConvergeOwnership() { var fixture = new Fixture(); _ = fixture.Prepare(); fixture.Movement.ResetSession(); Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); fixture.RepreparePlacement(); _ = fixture.Prepare(); fixture.Movement.Dispose(); Assert.True(fixture.Movement.CaptureOwnership().IsConverged); fixture.DisposeLifetimeOnly(); } [Theory] [InlineData(PristineViolation.Body)] [InlineData(PristineViolation.Controller)] [InlineData(PristineViolation.Host)] [InlineData(PristineViolation.BodyAcquisition)] [InlineData(PristineViolation.Remote)] [InlineData(PristineViolation.RemoteBinding)] [InlineData(PristineViolation.Projectile)] [InlineData(PristineViolation.ProjectileBinding)] [InlineData(PristineViolation.RemotePlacement)] public void PreparationRejectsEveryNonPristineOwnershipGraph( PristineViolation violation) { using var fixture = new Fixture(); switch (violation) { case PristineViolation.Body: { var activeBody = new PhysicsBody { TransientState = TransientStateFlags.Active, }; fixture.Lifetime.Entities.SetPhysicsBody( fixture.Record, activeBody); break; } case PristineViolation.Controller: fixture.Movement.Controller = new PlayerMovementController( new PhysicsEngine()); break; case PristineViolation.Host: fixture.Lifetime.Entities.SetPhysicsHost( fixture.Record, CreatePhysicsHost(fixture.Record.ServerGuid)); break; case PristineViolation.BodyAcquisition: fixture.Lifetime.Entities.SetPhysicsBodyAcquisitionInProgress( fixture.Record, true); break; case PristineViolation.Remote: fixture.Lifetime.Entities.SetRemoteMotion( fixture.Record, new RemoteMotion()); break; case PristineViolation.RemoteBinding: fixture.Lifetime.Entities.SetRemoteMotionBindingInProgress( fixture.Record, true); break; case PristineViolation.Projectile: fixture.Lifetime.Entities.SetProjectile( fixture.Record, new RuntimeProjectile( new PhysicsBody(), new ProjectileCollisionSphere(Vector3.Zero, 0.1f))); break; case PristineViolation.ProjectileBinding: fixture.Lifetime.Entities.SetProjectileBindingInProgress( fixture.Record, true); break; case PristineViolation.RemotePlacement: fixture.Lifetime.Entities.SetRequiresRemotePlacementRuntime( fixture.Record, true); break; default: throw new ArgumentOutOfRangeException(nameof(violation)); } Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, fixture.Owner.Prepare( fixture.Record, fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, out RuntimeLocalPlayerPhysicsPublicationToken token)); Assert.False(token.IsValid); Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount); } [Fact] public void PreparationRejectsRecordWhichIsNotTheExactLocalIdentity() { using var fixture = new Fixture(); fixture.Identity.ServerGuid = 0x70003002u; Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, fixture.Owner.Prepare( fixture.Record, fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, out _)); } [Fact] public void PreparationRejectsDisposedLocalIdentity() { using var fixture = new Fixture(); fixture.Identity.Dispose(); Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, fixture.Owner.Prepare( fixture.Record, fixture.Placement, fixture.Command, PlayerMovementConstructionOptions.Fallback, out _)); } [Fact] public void IdentityRevisionSwitchRejectsPreparedCandidate() { using var fixture = new Fixture(); RuntimeLocalPlayerPhysicsPublicationToken token = fixture.Prepare(); uint guid = fixture.Identity.ServerGuid; fixture.Identity.ServerGuid = guid + 1u; fixture.Identity.ServerGuid = guid; Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority, fixture.Owner.Commit(token)); Assert.Null(fixture.Record.PhysicsBody); Assert.Null(fixture.Movement.Controller); } [Theory] [InlineData(PublishedRetirement.Replacement)] [InlineData(PublishedRetirement.Reset)] [InlineData(PublishedRetirement.Dispose)] public void RuntimeOwnedControllerRejectsStaleOperationsAfterRetirement( PublishedRetirement retirement) { using var fixture = new Fixture(); Assert.Equal( RuntimeLocalPlayerPhysicsPublicationStatus.Committed, fixture.Owner.Commit(fixture.Prepare())); PlayerMovementController stale = fixture.Movement.Controller!; switch (retirement) { case PublishedRetirement.Replacement: fixture.Movement.Controller = new PlayerMovementController( new PhysicsEngine()); break; case PublishedRetirement.Reset: fixture.Movement.ResetSession(); break; case PublishedRetirement.Dispose: fixture.Movement.Dispose(); break; default: throw new ArgumentOutOfRangeException(nameof(retirement)); } AssertNotLive(stale); } [Fact] public void BodyAndControllerEpochsAdvanceOnlyOnOwnershipChanges() { using var fixture = new Fixture(preparePlacement: false); var firstBody = new PhysicsBody(); var secondBody = new PhysicsBody(); ulong bodyEpoch = fixture.Record.PhysicsOwnershipEpoch; fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, firstBody); Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch); fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, firstBody); Assert.Equal(bodyEpoch + 1UL, fixture.Record.PhysicsOwnershipEpoch); fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, secondBody); Assert.Equal(bodyEpoch + 2UL, fixture.Record.PhysicsOwnershipEpoch); fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, null); Assert.Equal(bodyEpoch + 3UL, fixture.Record.PhysicsOwnershipEpoch); var first = new PlayerMovementController(new PhysicsEngine()); var second = new PlayerMovementController(new PhysicsEngine()); ulong controllerEpoch = fixture.Movement.ControllerOwnershipEpoch; fixture.Movement.Controller = first; Assert.Equal(controllerEpoch + 1UL, fixture.Movement.ControllerOwnershipEpoch); fixture.Movement.Controller = first; Assert.Equal(controllerEpoch + 1UL, fixture.Movement.ControllerOwnershipEpoch); fixture.Movement.Controller = second; Assert.Equal(controllerEpoch + 2UL, fixture.Movement.ControllerOwnershipEpoch); fixture.Movement.Controller = null; Assert.Equal(controllerEpoch + 3UL, fixture.Movement.ControllerOwnershipEpoch); } private sealed class Fixture : IDisposable { private bool _lifetimeDisposed; internal Fixture(bool preparePlacement = true) { Lifetime = new RuntimeEntityObjectLifetime(); Movement = new RuntimeLocalPlayerMovementState(); Identity = new RuntimeLocalPlayerIdentityState(); Owner = new RuntimeLocalPlayerPhysicsPublicationState( Lifetime.Entities, Lifetime.Physics, Movement, Identity); Movement.AttachPhysicsPublication(Owner); Record = Lifetime.RegisterEntity( Spawn(0x70003001u, incarnation: 1)).Canonical!; Identity.ServerGuid = Record.ServerGuid; if (preparePlacement) RepreparePlacement(); } internal RuntimeEntityObjectLifetime Lifetime { get; } internal RuntimeLocalPlayerMovementState Movement { get; } internal RuntimeLocalPlayerIdentityState Identity { get; } internal RuntimeLocalPlayerPhysicsPublicationState Owner { get; } internal RuntimeEntityRecord Record { get; } internal RuntimeEntityPlacementToken Placement { get; private set; } internal RuntimeSetPositionCommand Command { get; private set; } internal void RepreparePlacement() { Placement = Lifetime.Physics.SetPosition.BeginAuthoredPlacement( Record, Record.PositionAuthorityVersion, RuntimeSetPositionOperationKind.LocalAuthoritative); Assert.True(Placement.IsValid); var setup = new FlatSetupCollision( ImmutableArray.Empty, [new FlatCollisionSphere(Vector3.Zero, 0.48f)], height: 0f, radius: 0f, stepUpHeight: 0.4f, stepDownHeight: 0.4f); Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, Lifetime.Physics.SetPosition.PrepareMover( Placement, new RuntimeSetPositionMoverPreparation( RuntimeSetPositionMoverSetup.Resolved(SetupId, setup), RuntimeSetPositionOperationKind.LocalAuthoritative, GameTime: 10d, PhysicsPlacementClass.Ordinary, PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide), out RuntimeSetPositionCommand command)); Command = command; } internal RuntimeLocalPlayerPhysicsPublicationToken Prepare() { Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared, Owner.Prepare( Record, Placement, Command, PlayerMovementConstructionOptions.Fallback, out RuntimeLocalPlayerPhysicsPublicationToken token)); Assert.Equal(Record.Key, token.Entity); Assert.Equal(Placement, token.Placement); Assert.Equal(Identity.ServerGuid, token.LocalPlayerServerGuid); Assert.Equal(Identity.Revision, token.LocalPlayerIdentityRevision); return token; } internal void DisposeLifetimeOnly() { if (_lifetimeDisposed) return; Lifetime.Dispose(); _lifetimeDisposed = true; } public void Dispose() { Movement.Dispose(); Identity.Dispose(); DisposeLifetimeOnly(); } } private static WorldSession.EntitySpawn Spawn(uint guid, ushort incarnation) { var position = new CreateObject.ServerPosition( Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f); var timestamps = new PhysicsTimestamps( Position: 1, Movement: 1, State: 1, Vector: 1, Teleport: 0, ServerControlledMove: 1, ForcePosition: 0, ObjDesc: 1, Instance: incarnation); var physics = new PhysicsSpawnData( RawState: (uint)(PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions), Position: position, Movement: null, AnimationFrame: null, SetupTableId: SetupId, MotionTableId: 0x09000001u, SoundTableId: null, PhysicsScriptTableId: null, Parent: null, Children: null, Scale: 1f, Friction: null, Elasticity: null, Translucency: null, Velocity: null, Acceleration: null, AngularVelocity: null, DefaultScriptType: null, DefaultScriptIntensity: null, Timestamps: timestamps); return new WorldSession.EntitySpawn( Guid: guid, Position: position, SetupTableId: SetupId, AnimPartChanges: Array.Empty(), TextureChanges: Array.Empty(), SubPalettes: Array.Empty(), BasePaletteId: null, ObjScale: 1f, Name: "local-publication-fixture", ItemType: null, MotionState: null, MotionTableId: 0x09000001u, PhysicsState: physics.RawState, ObjectDescriptionFlags: 0x8u, InstanceSequence: incarnation, MovementSequence: 1, ServerControlSequence: 1, PositionSequence: 1, Physics: physics); } public enum PublicationInvalidation { SetPositionReplacement, Vector, FinalState, ObjectDescription, Create, Remote, Projectile, Body, Clock, Controller, CancelPlacement, } public enum PristineViolation { Body, Controller, Host, BodyAcquisition, Remote, RemoteBinding, Projectile, ProjectileBinding, RemotePlacement, } public enum PublishedRetirement { Replacement, Reset, Dispose, } private static EntityPhysicsHost CreatePhysicsHost(uint id) => new( id, getPosition: static () => default, getVelocity: static () => Vector3.Zero, getRadius: static () => 0.48f, inContact: static () => true, minterpMaxSpeed: static () => null, curTime: static () => 0d, physicsTimerTime: static () => 0d, getObjectA: static _ => null, handleUpdateTarget: static _ => { }, interruptCurrentMovement: static () => { }); private static void AssertNotLive(PlayerMovementController controller) { Assert.Throws(() => controller.Update( 1f / 60f, default)); Assert.Throws(() => controller.TickHidden( 1f / 60f)); Assert.Throws(() => controller.SuspendObjectUpdate(1f / 60f)); Assert.Throws(() => controller.SetPosition( Vector3.One, Cell, Vector3.One)); Assert.Throws(() => controller.BlipPosition( Vector3.One, Cell, Vector3.One)); Assert.Throws(() => controller.ApplyPhysicsState(PhysicsStateFlags.Frozen)); Assert.Throws(() => controller.Yaw = 1f); Assert.Throws(() => controller.CaptureMovementResult(mouseLookEvent: false)); Assert.Throws(() => controller.CapturePresentationResult()); Assert.Throws(() => controller.TryGetOutboundPosition(out _)); Assert.Throws(() => controller.NoteMovementSent(1f)); Assert.Throws(() => controller.NotePositionSent(default, default, 1f)); Assert.Throws(() => controller.ShouldSendPositionEvent(default, default, 1f)); Assert.Throws(() => controller.BeginMouseLook(default)); Assert.Throws(() => controller.SubmitMouseTurnAdjustment(1f, default)); Assert.Throws(() => controller.StopMouseDrift(default)); Assert.Throws(() => controller.EndMouseLook(default)); Assert.Throws(() => controller.PrepareForAttackRequest()); Assert.Throws(() => controller.RequestPosture(MotionCommand.Ready)); Assert.Throws(() => controller.CommitPreparedPosition()); Assert.Throws(() => controller.PreparePositionForCommit(Vector3.One, Cell, Vector3.One)); Assert.Throws(() => controller.SetCharacterSkills(1, 1)); Assert.Throws(() => controller.SetBodyOrientation(Quaternion.Identity)); Assert.Throws(() => controller.SetLastMoveWasAutonomous(true)); Assert.Throws(() => controller.State = PlayerState.PortalSpace); Assert.Throws(() => controller.StepUpHeight = 1f); Assert.Throws(() => controller.AttachCycleVelocityAccessor(static () => Vector3.One)); Assert.Throws(() => controller.AttachAnimationRootMotionSource(static (_, _) => { })); Assert.Throws(() => _ = controller.Movement); Assert.Throws(() => _ = controller.MoveTo); Assert.Throws(() => _ = controller.PositionManager); Assert.Throws(() => _ = controller.Motion); Assert.Throws(() => _ = controller.PhysicsBody); } }