feat(physics): P5 commit 2 - arm the ConstraintManager leash on accepted positions (#167)
Wire ConstraintManager.ConstrainTo at every current acdream inbound-position acceptance seam, matching retail SmartBox::HandleReceivedPosition (0x00453fd0): - Remote (player + NPC): LiveEntityNetworkUpdateController arms right after the hard-teleport branch (remotePlacementRequired) returns - reaching that point already means MoveOrTeleport did NOT hard-place - anchored to the object's own live IPhysicsObjHost.Position. - Local player teleport: PlayerMovementController.SetPositionCore now runs UnConstrain (retail teleport_hook 0x00514ed0, previously a no-op because nothing armed the leash) then re-arms anchored to the just-snapped position, composing with the existing StopCompletelyAtPhysicsObjectBoundary velocity zero rather than duplicating it. CommitPreparedPosition mirrors the same pair for the deferred player-mode-entry commit path. - Local player ForcePosition: PlayerMovementController.BlipPosition arms with NO preceding UnConstrain (retail BlipPlayer/SetPositionSimple survives motion/velocity/stick, and the leash is no different). Push PhysicsBody.IsFullyConstrained from PositionManager.IsFullyConstrained at the SAME per-tick chokepoint each pump already runs AdjustOffset (PlayerMovementController.Update, RuntimeRemotePhysicsUpdater.Tick/TickHidden) so TS-35's read gate in jump_is_allowed sees live state instead of a stub that is never written. Tests: local-player arm/teardown/rearm/taper/jump-refusal (Runtime.Tests, PlayerMovementControllerTests), remote-tick IsFullyConstrained push (Runtime.Tests, RuntimePhysicsStateTests). Full Core/Runtime/App suites green with no regressions.
This commit is contained in:
parent
378d0b6ca0
commit
7719d25bc5
5 changed files with 330 additions and 3 deletions
|
|
@ -1,7 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
|
|
@ -868,4 +871,166 @@ public class PlayerMovementControllerTests
|
|||
Assert.True(weenie.InqRunRate(out float restored));
|
||||
Assert.Equal(baseline, restored, precision: 4);
|
||||
}
|
||||
|
||||
// ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ──
|
||||
//
|
||||
// docs/research/2026-07-30-constraint-leash-constants.md. The player's
|
||||
// PositionManager is handed in by EnterPlayerModeNow in production; these
|
||||
// tests wire an EntityPhysicsHost the same way so SetPosition/BlipPosition's
|
||||
// new ConstrainTo/UnConstrain calls have somewhere real to land.
|
||||
|
||||
private static (PlayerMovementController Controller, EntityPhysicsHost Host)
|
||||
MakeControllerWithHost()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var hosts = new Dictionary<uint, IPhysicsObjHost>();
|
||||
const uint selfGuid = 0x5000000Au;
|
||||
var host = new EntityPhysicsHost(
|
||||
selfGuid,
|
||||
getPosition: () => controller.CellPosition,
|
||||
getVelocity: () => controller.BodyVelocity,
|
||||
getRadius: () => 0.5f,
|
||||
inContact: () => controller.BodyInContact,
|
||||
minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(),
|
||||
curTime: () => 0.0,
|
||||
physicsTimerTime: () => 0.0,
|
||||
getObjectA: id => hosts.TryGetValue(id, out var h) ? h : null,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
hosts[selfGuid] = host;
|
||||
controller.PositionManager = host.PositionManager;
|
||||
return (controller, host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPosition_Teleport_ArmsConstraintAnchoredToReceivedPositionOutdoor()
|
||||
{
|
||||
var (controller, _) = MakeControllerWithHost();
|
||||
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); // low16 < 0x0100 -> outdoor
|
||||
|
||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||
Assert.True(cm.IsConstrained);
|
||||
Assert.Equal(10.0f, cm.ConstraintDistanceStart); // ACE-inversion pin: NOT 5
|
||||
Assert.Equal(50.0f, cm.ConstraintDistanceMax);
|
||||
Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
|
||||
// Anchored to self (the just-received position) -> zero offset at arm time.
|
||||
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
|
||||
// R3-W6's teleport idle (StopCompletelyAtPhysicsObjectBoundary) still
|
||||
// zeroes velocity — the new UnConstrain/ConstrainTo calls compose with
|
||||
// it rather than replacing it.
|
||||
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPosition_Teleport_IndoorCellUsesTheTighterBand()
|
||||
{
|
||||
var (controller, _) = MakeControllerWithHost();
|
||||
|
||||
controller.SetPosition(
|
||||
new Vector3(10f, 10f, 5f),
|
||||
0x01000105u); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
|
||||
|
||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||
Assert.Equal(5.0f, cm.ConstraintDistanceStart);
|
||||
Assert.Equal(20.0f, cm.ConstraintDistanceMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPosition_Teleport_TearsDownAndRearmsAPreviouslyFullyConstrainedLeash()
|
||||
{
|
||||
var (controller, _) = MakeControllerWithHost();
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||
|
||||
// Synthetically over-strain the leash with a tight band (production
|
||||
// arming uses the real 10/50 m band; a tight synthetic band here just
|
||||
// makes "was fully constrained before this teleport" cheap to reach).
|
||||
cm.ConstrainTo(controller.CellPosition, startDistance: 0.1f, maxDistance: 0.2f);
|
||||
cm.AdjustOffset(new MotionDeltaFrame { Origin = new Vector3(5f, 0f, 0f) }, quantum: 0.1);
|
||||
Assert.True(controller.PositionManager.IsFullyConstrained());
|
||||
|
||||
controller.SetPosition(new Vector3(150f, 150f, 50f), 0x0001);
|
||||
|
||||
// retail teleport_hook's UnConstrain, followed by the fresh re-arm at
|
||||
// the new position, clears the stale over-strained state.
|
||||
Assert.False(controller.PositionManager.IsFullyConstrained());
|
||||
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlipPosition_ArmsConstraintButDoesNotTearDownOrZeroVelocity()
|
||||
{
|
||||
var (controller, _) = MakeControllerWithHost();
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
Vector3 velocityBeforeBlip = controller.BodyVelocity;
|
||||
Assert.NotEqual(Vector3.Zero, velocityBeforeBlip); // sanity: actually moving
|
||||
|
||||
controller.BlipPosition(
|
||||
new Vector3(150f, 150f, 50f),
|
||||
0x0001,
|
||||
new Vector3(150f, 150f, 50f));
|
||||
|
||||
// BlipPlayer (retail 0x00453940) survives motion/velocity/stick — the
|
||||
// leash is no different: ConstrainTo runs with NO preceding UnConstrain
|
||||
// and no StopCompletely.
|
||||
Assert.Equal(velocityBeforeBlip, controller.BodyVelocity);
|
||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||
Assert.True(cm.IsConstrained);
|
||||
Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
|
||||
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstraintArmedInBand_TapersALargeRootMotionOffsetOnTheSecondTick()
|
||||
{
|
||||
var (controller, _) = MakeControllerWithHost();
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 10f);
|
||||
|
||||
controller.AttachAnimationRootMotionSource((dt, frame) =>
|
||||
{
|
||||
frame.Origin = new Vector3(5f, 0f, 0f); // wildly large per-tick root motion, on purpose
|
||||
});
|
||||
|
||||
// Tick 1: the gate reads the OFFSET RECORDED BEFORE this tick (0, from
|
||||
// ConstrainTo, below start) -- passes through close to the raw 5 m.
|
||||
Vector3 beforeTick1 = controller.Position;
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
float displacement1 = (controller.Position - beforeTick1).Length();
|
||||
Assert.True(displacement1 > 4.0f,
|
||||
$"first tick should pass through near-unscaled, got {displacement1}");
|
||||
|
||||
// Tick 2: ConstraintPosOffset is now ~5 (recorded from tick 1), inside
|
||||
// the (1,10) band -- the linear taper now visibly brakes the SAME 5 m
|
||||
// raw input.
|
||||
Vector3 beforeTick2 = controller.Position;
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
float displacement2 = (controller.Position - beforeTick2).Length();
|
||||
Assert.True(displacement2 > 0f);
|
||||
Assert.True(displacement2 < 4.0f,
|
||||
$"second tick should be tapered well below the raw 5 m input, got {displacement2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstraintOverstrained_PushesIsFullyConstrainedOntoBodyAndBlocksJump()
|
||||
{
|
||||
var (controller, _) = MakeControllerWithHost();
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
ConstraintManager cm = controller.PositionManager!.Constraint!;
|
||||
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 2f);
|
||||
|
||||
controller.AttachAnimationRootMotionSource((dt, frame) =>
|
||||
{
|
||||
frame.Origin = new Vector3(10f, 0f, 0f); // one huge tick, past max
|
||||
});
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
|
||||
Assert.True(controller.PositionManager!.IsFullyConstrained());
|
||||
WeenieError result = controller.Motion.jump_is_allowed(1.0f, out _);
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue