diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 52776715..fb683ea5 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -98,6 +98,7 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionWorldRuntime _world; private readonly LiveSessionCommandSurface _commands; private readonly Action _log; + private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -189,6 +190,7 @@ internal sealed class LiveSessionRuntimeFactory private void ResetPlayerPresentation() { + _staminaExhaustion.Reset(); _interaction.PlayerMode.ResetSession(); _world.SpawnClaims.Reset(); } @@ -289,13 +291,6 @@ internal sealed class LiveSessionRuntimeFactory OnMovementStatsUpdated: () => ApplyMovementStats("stats")); } - /// - /// Tracks the previous "stamina exhausted" state so - /// can fire retail's exhaustion - /// notification on the EDGE only. Null = no stamina reading applied yet. - /// - private bool? _lastStaminaExhausted; - /// /// Re-applies the current /// snapshot (skills/burden/stamina) to the live player controller. @@ -328,14 +323,8 @@ internal sealed class LiveSessionRuntimeFactory } RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot; - bool exhausted = snapshot.CurrentStamina == 0; - if (_lastStaminaExhausted != exhausted) - { - bool isEdge = _lastStaminaExhausted is not null; - _lastStaminaExhausted = exhausted; - if (isEdge) - controller!.Motion.ReportExhaustion(); - } + if (_staminaExhaustion.Observe(snapshot.CurrentStamina)) + controller!.Motion.ReportExhaustion(); _log( $"player: applied server movement {reason} " diff --git a/src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs b/src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs new file mode 100644 index 00000000..0fce7ea1 --- /dev/null +++ b/src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs @@ -0,0 +1,32 @@ +namespace AcDream.App.Net; + +/// +/// Tracks retail's stamina-exhaustion notification edge for one live-session +/// generation. +/// +internal sealed class StaminaExhaustionEdgeTracker +{ + private bool? _wasExhausted; + + /// + /// Observes the current stamina value and returns + /// only when a previously initialized state changes between exhausted and + /// non-exhausted. + /// + public bool Observe(int currentStamina) + { + bool exhausted = currentStamina == 0; + if (_wasExhausted == exhausted) + return false; + + bool isEdge = _wasExhausted.HasValue; + _wasExhausted = exhausted; + return isEdge; + } + + /// + /// Forgets the retiring character/session sample. The first sample in the + /// next generation establishes a baseline and must not synthesize an edge. + /// + public void Reset() => _wasExhausted = null; +} diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 8ea2bffb..870e9d47 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -168,9 +168,6 @@ internal sealed class LiveEntityNetworkUpdateController System.Numerics.Vector3 worldPos, uint cellId) { - if (cellId == 0) - return; - var (radius, height) = _motionRuntime.GetSetupCylinder(serverGuid, entity); if (radius < 0.05f) { @@ -193,33 +190,20 @@ internal sealed class LiveEntityNetworkUpdateController // retail's first frame would (position snapped onto the floor, // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that // finds no floor (true airborne spawn) leaves the body airborne. - AcDream.Core.Physics.ResolveResult settle = _physicsEngine.ResolveWithTransition( - worldPos, - worldPos + new System.Numerics.Vector3(0f, 0f, -0.5f), - cellId, - sphereRadius: radius, - sphereHeight: height, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, - isOnGround: false, - body: remote.Body, - moverFlags: moverFlags, - movingEntityId: entity.Id); - if (!settle.Ok || !settle.InContact) - return; // no floor within reach — stays airborne like retail's fall - - remote.Body.Position = settle.Position; - - AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( + if (!RemoteSpawnPlacementSettler.TrySettle( + _physicsEngine, remote.Body, - settle.InContact, - settle.OnWalkable, - settle.CollisionNormalValid, - settle.CollisionNormal, - previousContact: false, - previousOnWalkable: false, + worldPos, + cellId, + radius, + height, + moverFlags, + entity.Id, remote.Movement.HitGround, - remote.Motion.LeaveGround); + remote.Motion.LeaveGround)) + { + return; // no floor within reach — stays airborne like retail's fall + } remote.Airborne = !remote.Body.OnWalkable; } diff --git a/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs b/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs new file mode 100644 index 00000000..6ea506d0 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs @@ -0,0 +1,62 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Performs the compressed first-gravity-frame settle used to establish +/// retail Contact/OnWalkable state for a newly materialized remote body. +/// +internal static class RemoteSpawnPlacementSettler +{ + internal const float SettleDistance = 0.5f; + + public static bool TrySettle( + PhysicsEngine physicsEngine, + PhysicsBody body, + Vector3 worldPosition, + uint cellId, + float sphereRadius, + float sphereHeight, + ObjectInfoState moverFlags, + uint movingEntityId, + Action hitGround, + Action leaveGround) + { + ArgumentNullException.ThrowIfNull(physicsEngine); + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(hitGround); + ArgumentNullException.ThrowIfNull(leaveGround); + + if (cellId == 0) + return false; + + ResolveResult settle = physicsEngine.ResolveWithTransition( + worldPosition, + worldPosition - new Vector3(0f, 0f, SettleDistance), + cellId, + sphereRadius, + sphereHeight, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: false, + body, + moverFlags, + movingEntityId); + if (!settle.Ok || !settle.InContact) + return false; + + body.Position = settle.Position; + PhysicsObjUpdate.CommitSetPositionTransition( + body, + settle.InContact, + settle.OnWalkable, + settle.CollisionNormalValid, + settle.CollisionNormal, + previousContact: false, + previousOnWalkable: false, + hitGround, + leaveGround); + return true; + } +} diff --git a/tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs b/tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs new file mode 100644 index 00000000..c05290cf --- /dev/null +++ b/tests/AcDream.App.Tests/Net/StaminaExhaustionEdgeTrackerTests.cs @@ -0,0 +1,52 @@ +using AcDream.App.Net; + +namespace AcDream.App.Tests.Net; + +public sealed class StaminaExhaustionEdgeTrackerTests +{ + [Theory] + [InlineData(100)] + [InlineData(0)] + public void FirstSample_EstablishesBaselineWithoutNotification(int stamina) + { + var tracker = new StaminaExhaustionEdgeTracker(); + + Assert.False(tracker.Observe(stamina)); + } + + [Fact] + public void RepeatedStatsTicks_DoNotRedispatchMovement() + { + var tracker = new StaminaExhaustionEdgeTracker(); + + Assert.False(tracker.Observe(100)); + Assert.False(tracker.Observe(99)); + Assert.False(tracker.Observe(50)); + Assert.False(tracker.Observe(1)); + } + + [Fact] + public void ExhaustedStateTransitions_ReportExactlyOncePerEdge() + { + var tracker = new StaminaExhaustionEdgeTracker(); + + Assert.False(tracker.Observe(50)); + Assert.True(tracker.Observe(0)); + Assert.False(tracker.Observe(0)); + Assert.True(tracker.Observe(1)); + Assert.False(tracker.Observe(80)); + } + + [Fact] + public void Reset_PreventsCrossGenerationSyntheticEdge() + { + var tracker = new StaminaExhaustionEdgeTracker(); + Assert.False(tracker.Observe(50)); + Assert.True(tracker.Observe(0)); + + tracker.Reset(); + + Assert.False(tracker.Observe(75)); + Assert.True(tracker.Observe(0)); + } +} diff --git a/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs new file mode 100644 index 00000000..dbd1b5d4 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Physics; + +public sealed class Issue270ProductionWiringTests +{ + [Fact] + public void MovementStats_UseOneEdgeTrackerAndResetItWithTheSession() + { + string source = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); + + Assert.Contains( + "_staminaExhaustion.Observe(snapshot.CurrentStamina)", + source, + StringComparison.Ordinal); + Assert.Single( + Regex.Matches( + source, + @"controller!\.Motion\.ReportExhaustion\(\);") + .Cast()); + Assert.Contains( + "_staminaExhaustion.Reset();", + source, + StringComparison.Ordinal); + } + + [Fact] + public void RemoteSpawnSettle_IsRetriedAndCoversBothCreationRoutes() + { + string source = ReadSource( + "Physics", + "LiveEntityNetworkUpdateController.cs"); + + Assert.Contains( + "if (!remote.Body.InContact)", + source, + StringComparison.Ordinal); + Assert.Equal( + 3, + Regex.Matches(source, @"SeedRemoteSpawnPlacement\(").Count); + Assert.Contains( + "RemoteSpawnPlacementSettler.TrySettle(", + source, + StringComparison.Ordinal); + } + + private static string ReadSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs new file mode 100644 index 00000000..cbef8c97 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs @@ -0,0 +1,109 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Physics; + +public sealed class RemoteSpawnPlacementSettlerTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + private const float Radius = 0.48f; + private const float Height = 1.835f; + + [Fact] + public void FloorWithinFirstGravityFrame_CommitsGroundContactOnce() + { + PhysicsEngine engine = BuildFlatEngine(); + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 0.25f)); + int hitGround = 0; + int leaveGround = 0; + + bool settled = RemoteSpawnPlacementSettler.TrySettle( + engine, + body, + body.Position, + Cell, + Radius, + Height, + ObjectInfoState.EdgeSlide, + movingEntityId: 0x70000001u, + () => hitGround++, + () => leaveGround++); + + Assert.True(settled); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.Equal(1, hitGround); + Assert.Equal(0, leaveGround); + } + + [Fact] + public void MissingCell_CanRetryAfterHydrationWithoutReconstructingBody() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 0.25f)); + Vector3 originalPosition = body.Position; + + Assert.False(TrySettle(engine, body)); + Assert.False(body.InContact); + Assert.Equal(originalPosition, body.Position); + + AddFlatLandblock(engine); + + Assert.True(TrySettle(engine, body)); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + } + + [Fact] + public void FloorOutsideSettleDistance_LeavesGenuinelyAirborneBodyUnchanged() + { + PhysicsEngine engine = BuildFlatEngine(); + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 1.25f)); + Vector3 originalPosition = body.Position; + + Assert.False(TrySettle(engine, body)); + + Assert.False(body.InContact); + Assert.False(body.OnWalkable); + Assert.Equal(originalPosition, body.Position); + } + + private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) => + RemoteSpawnPlacementSettler.TrySettle( + engine, + body, + body.Position, + Cell, + Radius, + Height, + ObjectInfoState.EdgeSlide, + movingEntityId: 0x70000001u, + static () => { }, + static () => { }); + + private static PhysicsBody AirborneBody(Vector3 position) => new() + { + Position = position, + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.None, + }; + + private static PhysicsEngine BuildFlatEngine() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + AddFlatLandblock(engine); + return engine; + } + + private static void AddFlatLandblock(PhysicsEngine engine) => + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); +}