test(physics): pin issue 270 animation fixes
This commit is contained in:
parent
bb1640f777
commit
0cb60d98a0
7 changed files with 337 additions and 43 deletions
|
|
@ -98,6 +98,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly LiveSessionWorldRuntime _world;
|
||||
private readonly LiveSessionCommandSurface _commands;
|
||||
private readonly Action<string> _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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the previous "stamina exhausted" state so
|
||||
/// <see cref="ApplyMovementStats"/> can fire retail's exhaustion
|
||||
/// notification on the EDGE only. Null = no stamina reading applied yet.
|
||||
/// </summary>
|
||||
private bool? _lastStaminaExhausted;
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
|
||||
/// 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} "
|
||||
|
|
|
|||
32
src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs
Normal file
32
src/AcDream.App/Net/StaminaExhaustionEdgeTracker.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks retail's stamina-exhaustion notification edge for one live-session
|
||||
/// generation.
|
||||
/// </summary>
|
||||
internal sealed class StaminaExhaustionEdgeTracker
|
||||
{
|
||||
private bool? _wasExhausted;
|
||||
|
||||
/// <summary>
|
||||
/// Observes the current stamina value and returns <see langword="true"/>
|
||||
/// only when a previously initialized state changes between exhausted and
|
||||
/// non-exhausted.
|
||||
/// </summary>
|
||||
public bool Observe(int currentStamina)
|
||||
{
|
||||
bool exhausted = currentStamina == 0;
|
||||
if (_wasExhausted == exhausted)
|
||||
return false;
|
||||
|
||||
bool isEdge = _wasExhausted.HasValue;
|
||||
_wasExhausted = exhausted;
|
||||
return isEdge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the retiring character/session sample. The first sample in the
|
||||
/// next generation establishes a baseline and must not synthesize an edge.
|
||||
/// </summary>
|
||||
public void Reset() => _wasExhausted = null;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
62
src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs
Normal file
62
src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Performs the compressed first-gravity-frame settle used to establish
|
||||
/// retail Contact/OnWalkable state for a newly materialized remote body.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Match>());
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue