acdream/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs
Erik 5bd2b8bc8b fix(#171): R5-V3 — bind sticky melee (StickyManager live) + real arrival radii
Group-melee interpenetration + facing drift: the R5-V1-ported
StickyManager/PositionManager were Core-only — the StickTo/Unstick seams
were unbound and every arrival radius was 0, so ACE's Sticky|UseSpheres
melee chases closed ~one body-radius too deep and froze at stale arrival
poses until the next wire re-arm. Retires TS-39.

Wiring (anchors re-verified against the named decomp this session):
- EntityPhysicsHost owns a PositionManager; HandleUpdateTarget fans
  MoveToManager then PositionManager (CPhysicsObj::HandleUpdateTarget
  0x00512bc0 order).
- Seams bound remote + player: MoveToManager.StickTo (BeginNextNode
  sticky arrival @0x00529d3a), Unstick (PerformMovement head), and
  MotionInterpreter.UnstickFromObject (UM funnel head, 0x0050eaea).
- AdjustOffset at the retail UpdatePositionInternal slot (@0x00512d0e):
  NPC branch composes pre-sweep (steer is swept by ResolveWithTransition);
  remote-player branch chains the combiner offset through the shared
  delta frame (the interp stage) so sticky OVERWRITES when armed
  (0x00555430 assigns m_fOrigin, not accumulates); player inside the
  30 Hz physics quantum before UpdatePhysicsInternal.
- UseTime (the 1 s lease watchdog) at the UpdateObjectInternal tail
  (@0x005159b3): unconditional per remote; player gated on the physics
  tick (retail's MinQuantum gate skips UseTime too).
- Real setup cylsphere radii (CPartArray::GetRadius/GetHeight
  0x005180a0/0x005180b0 = setup radius/height x ObjScale from the spawn
  record): own via EnsureRemoteMotionBindings + player wiring; target via
  RouteServerMoveTo AND the speculative use-walk install (retail resolves
  the target PartArray at EVERY MoveToObject site — ACE PhysicsObj.cs:951).
- Teardown parity: exit_world (0x00514e60) UnStick + ClearTarget before
  the ExitWorld notify; player teleport fires teleport_hook's tail
  (UnStick in SetPosition + EntityPhysicsHost.NotifyTeleported =
  ClearTarget + NotifyVoyeurOfEvent(Teleported) @0x00514f1b) so mobs
  stuck to the player drop their sticks on a recall.
- SERVERVEL arbitration also yields to a stuck entity (same starvation
  class as the #170 fix — sticky owns the between-snap translation).
- StickyManager.UseTime aligned to retail's strict > deadline
  (0x00555626; ACE >): two V1 tests had pinned the >= edge — corrected.

Register: TS-39 deleted; TS-41 narrowed (stickyArmed gate); TS-43 added
(remote teleport_hook gap — self-corrects within the 1 s lease); AP-23
narrowed (real radii at the speculative site; only the use-radius
buckets remain invented).

Conformance: 2 new full-stack sticky scenarios in
RemoteChaseEndToEndHarnessTests (arrive -> stick -> strafing-target
gap+facing track -> lease expiry; unstick-on-rearm -> re-stick).
Full suite 4038 green.

Pre-commit adversarial diff review (3 lenses + per-finding refuters)
confirmed and fixed 4 findings: ObjScale-dead radius read, player
UseTime order inversion, missing teleport voyeur notify, speculative-
site radius asymmetry.

Awaiting the user visual gate: pack melee side-by-side vs retail
(attackers reshuffle + keep facing; some overlap is ACE-server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:46:17 +02:00

114 lines
3.6 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance — <see cref="PositionManager"/> facade (retail
/// 0x00555160-0x005553d0). Lazy sub-manager creation + fan-out. (Test class is
/// suffixed "Facade" to read distinctly from the renamed
/// <c>RemoteMotionCombiner</c> combiner tests.)
/// </summary>
public sealed class PositionManagerFacadeTests
{
private static (R5Host self, R5Host target, PositionManager pm) Setup()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(20u, world);
return (self, target, new PositionManager(self));
}
[Fact]
public void UnStick_BeforeAnyStick_IsNoOp()
{
var (_, _, pm) = Setup();
pm.UnStick(); // no sticky manager yet — must not throw
Assert.Null(pm.Sticky);
Assert.Equal(0u, pm.GetStickyObjectId());
}
[Fact]
public void StickTo_LazilyCreatesSticky_AndForwards()
{
var (self, target, pm) = Setup();
pm.StickTo(target.Id, radius: 0.5f, height: 1.0f);
Assert.NotNull(pm.Sticky);
Assert.Equal(target.Id, pm.GetStickyObjectId());
}
[Fact]
public void IsFullyConstrained_FalseWhenNoConstraintManager()
{
var (_, _, pm) = Setup();
Assert.False(pm.IsFullyConstrained());
Assert.Null(pm.Constraint);
}
[Fact]
public void ConstrainTo_LazilyCreatesConstraint()
{
var (self, _, pm) = Setup();
self.SetOrigin(Vector3.Zero);
pm.ConstrainTo(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), 2f, 10f);
Assert.NotNull(pm.Constraint);
Assert.True(pm.Constraint!.IsConstrained);
}
[Fact]
public void HandleUpdateTarget_ForwardsToSticky()
{
var (self, target, pm) = Setup();
pm.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity);
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
Assert.True(pm.Sticky!.Initialized);
}
[Fact]
public void AdjustOffset_ChainsStickyThenConstraint()
{
var (self, target, pm) = Setup();
self.Radius = 0.5f;
self.MinterpMaxSpeed = 1.0f;
self.InContact = true;
// Sticky toward +X: produces an offset the constraint then tapers.
pm.StickTo(target.Id, 0.5f, 1.0f);
target.SetOrigin(new Vector3(5f, 0f, 0f));
var tp = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity);
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
// Arm a constraint whose band tapers by 0.5.
self.SetOrigin(Vector3.Zero);
pm.ConstrainTo(new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity),
startDistance: 2f, maxDistance: 10f); // offset 6 → taper (10-6)/(10-2)=0.5
var frame = new MotionDeltaFrame();
pm.AdjustOffset(frame, quantum: 0.1);
// Sticky writes +0.5 X; constraint tapers by 0.5 → 0.25.
Assert.Equal(0.25f, frame.Origin.X, 3);
}
[Fact]
public void UseTime_DrivesStickyTimeout()
{
var (self, target, pm) = Setup();
self.CurTime = 100.0;
pm.StickTo(target.Id, 0.5f, 1.0f); // deadline 101
// Strictly past the deadline (retail 0x00555626 keeps the stick AT
// the deadline; teardown is `>` — ACE too).
self.CurTime = 101.001;
pm.UseTime();
Assert.Equal(0u, pm.GetStickyObjectId()); // unstuck
}
}