fix(gameplay): reconcile wield ownership and target facing

Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks.

Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state.

Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration.

Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 08:19:23 +02:00
parent b26f84cc69
commit 7b7ffcd278
36 changed files with 3884 additions and 761 deletions

View file

@ -179,6 +179,42 @@ public sealed class CombatAttackControllerTests
Assert.Equal(0, cancels);
}
[Fact]
public void StartAttackRequest_PreparesPlayerMovementBeforePowerBuild()
{
var events = new List<string>();
var combat = new CombatState();
CombatAttackController? controller = null;
controller = new CombatAttackController(
combat,
canStartAttack: () => true,
sendAttack: (_, _) =>
{
events.Add("send");
return true;
},
prepareAttackRequest: () =>
{
Assert.True(controller!.AttackRequestInProgress);
Assert.Equal(1f, controller.RequestedAttackPower);
events.Add("prepare");
});
using (controller)
{
combat.SetCombatMode(CombatMode.Missile);
controller.SetDesiredPower(0f);
controller.PressAttack(AttackHeight.Medium);
Assert.Equal(new[] { "prepare" }, events);
Assert.True(controller.AttackRequestInProgress);
Assert.True(controller.BuildInProgress);
controller.ReleaseAttack();
Assert.Equal(new[] { "prepare", "send" }, events);
}
}
private static CombatAttackController Create(
CombatState combat,
Func<double> now,

View file

@ -0,0 +1,452 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.App.Tests.Input;
public sealed class PlayerMouseLookMovementTests
{
[Fact]
public void KeyboardMotionEdges_StillRequestImmediateMovementEvents()
{
var controller = CreateController();
MovementResult pressed = controller.Update(
1f / 60f,
new MovementInput(Forward: true));
MovementResult held = controller.Update(
1f / 60f,
new MovementInput(Forward: true));
MovementResult released = controller.Update(
1f / 60f,
new MovementInput());
Assert.True(pressed.ShouldSendMovementEvent);
Assert.False(held.ShouldSendMovementEvent);
Assert.True(released.ShouldSendMovementEvent);
}
[Fact]
public void MouseAdjustment_UsesTurnMotionInsteadOfDirectYawMutation()
{
var controller = CreateController();
Assert.True(controller.BeginMouseLook(new MovementInput()));
controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false);
float yawBefore = controller.Yaw;
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput());
MovementResult turning = controller.Update(1f / 60f, new MovementInput());
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
Assert.Equal(0.8f, turning.TurnSpeed);
Assert.True(controller.Yaw < yawBefore);
Assert.False(turning.ShouldSendMovementEvent);
Assert.True(turning.TurnUsesRunHold);
MovementResult held = controller.Update(1f / 60f, new MovementInput());
Assert.Equal(MotionCommand.TurnRight, held.TurnCommand);
controller.StopMouseDrift(new MovementInput());
MovementResult stopped = controller.Update(1f / 60f, new MovementInput());
Assert.Null(stopped.TurnCommand);
Assert.False(stopped.ShouldSendMovementEvent);
}
[Fact]
public void MouseLook_ReportsStartCadenceAndStopWithoutPerSampleFlooding()
{
var controller = CreateController();
Assert.True(controller.BeginMouseLook(new MovementInput()));
MovementResult begin = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(begin.ShouldSendMovementEvent);
Assert.False(begin.IsMouseLookMovementEvent);
controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false);
controller.SubmitMouseTurnAdjustment(0.5f, new MovementInput());
MovementResult sample = controller.Update(0.01f, new MovementInput());
Assert.Equal(MotionCommand.TurnLeft, sample.TurnCommand);
Assert.False(sample.ShouldSendMovementEvent);
controller.Update(
PlayerMovementController.MouseMovementEventInterval,
new MovementInput());
controller.SubmitMouseTurnAdjustment(0.5f, new MovementInput());
MovementResult cadence = controller.Update(
0.001f,
new MovementInput());
Assert.True(cadence.ShouldSendMovementEvent);
Assert.True(cadence.IsMouseLookMovementEvent);
controller.NoteMovementSent(
controller.SimTimeSeconds,
mouseLookEvent: true);
Assert.True(controller.EndMouseLook(new MovementInput()));
MovementResult ended = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(ended.ShouldSendMovementEvent);
Assert.False(ended.IsMouseLookMovementEvent);
Assert.Null(ended.TurnCommand);
}
[Fact]
public void MouseMovementEvent_IsRetriedUntilSuccessfulSendIsAcknowledged()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.Update(
PlayerMovementController.MouseMovementEventInterval + 0.001f,
new MovementInput());
controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput());
MovementResult first = controller.Update(0.01f, new MovementInput(Run: true));
MovementResult retry = controller.Update(0.01f, new MovementInput(Run: true));
Assert.True(first.ShouldSendMovementEvent);
Assert.True(first.IsMouseLookMovementEvent);
Assert.True(retry.ShouldSendMovementEvent);
controller.NoteMovementSent(
controller.SimTimeSeconds,
mouseLookEvent: true);
MovementResult acknowledged = controller.Update(
0.01f,
new MovementInput(Run: true));
Assert.False(acknowledged.ShouldSendMovementEvent);
}
[Fact]
public void MouseLook_RapidPressReleaseCanBeCapturedSynchronously()
{
var controller = CreateController();
var input = new MovementInput(Run: true);
Assert.True(controller.BeginMouseLook(input));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(controller.EndMouseLook(input));
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.True(press.ShouldSendMovementEvent);
Assert.False(press.IsMouseLookMovementEvent);
Assert.True(release.ShouldSendMovementEvent);
Assert.False(release.IsMouseLookMovementEvent);
}
[Fact]
public void MouseLook_CadenceUsesStrictHalfSecondBoundary()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.NoteMovementSent(nowSeconds: 0f, mouseLookEvent: true);
controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput());
MovementResult boundary = controller.Update(
PlayerMovementController.MouseMovementEventInterval,
new MovementInput());
Assert.False(boundary.ShouldSendMovementEvent);
controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput());
MovementResult justOver = controller.Update(
0.0001f,
new MovementInput());
Assert.True(justOver.ShouldSendMovementEvent);
Assert.True(justOver.IsMouseLookMovementEvent);
}
[Fact]
public void MouseTurn_UsesPerAxisRunHoldEvenInWalkMode()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.NoteMovementSent(nowSeconds: 0f, mouseLookEvent: true);
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput());
MovementResult turning = controller.Update(
0.01f,
new MovementInput(Run: false));
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
Assert.False(turning.IsRunning);
Assert.True(turning.TurnUsesRunHold);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.TurnHoldKey);
}
[Fact]
public void MouseLookRelease_SynchronouslyRestoresHeldKeyboardTurn()
{
var controller = CreateController();
controller.Update(
0.01f,
new MovementInput(TurnLeft: true, Run: true));
var held = new MovementInput(TurnLeft: true, Run: true);
controller.BeginMouseLook(held);
controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false);
controller.SubmitMouseTurnAdjustment(-0.4f, held);
controller.Update(0.01f, held);
controller.EndMouseLook(held);
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(MotionCommand.TurnLeft, release.TurnCommand);
Assert.Equal(1f, release.TurnSpeed);
Assert.False(release.TurnUsesRunHold);
}
[Fact]
public void MouseLook_RemapMovesHeldKeyboardTurnToSidestepAndBack()
{
var controller = CreateController();
var held = new MovementInput(TurnLeft: true, Run: true);
controller.Update(0.01f, held);
Assert.True(controller.BeginMouseLook(held));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(press.TurnCommand);
Assert.Equal(MotionCommand.SideStepLeft, press.SidestepCommand);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.SidestepHoldKey);
Assert.True(controller.EndMouseLook(held));
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(release.SidestepCommand);
Assert.Equal(MotionCommand.TurnLeft, release.TurnCommand);
Assert.False(release.TurnUsesRunHold);
}
[Fact]
public void MouseLookEntry_PreservesHeldForwardAndRunAfterTakingControl()
{
var controller = CreateController();
var held = new MovementInput(Forward: true, Run: true);
Assert.True(controller.BeginMouseLook(held));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(MotionCommand.WalkForward, press.ForwardCommand);
Assert.True(press.IsRunning);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
}
[Fact]
public void MouseLookEntry_AlreadyAutonomousSameFrameForwardEdgeIsNotLost()
{
var controller = CreateController();
controller.Update(0.01f, new MovementInput(Forward: true));
controller.Update(0.01f, new MovementInput());
var justPressed = new MovementInput(Forward: true, Run: true);
controller.BeginMouseLook(justPressed);
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(MotionCommand.WalkForward, press.ForwardCommand);
Assert.True(press.IsRunning);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
}
[Fact]
public void MouseLookEntry_AlreadyAutonomousSameFrameRunChangeIsNotLost()
{
var controller = CreateController();
controller.Update(0.01f, new MovementInput(Forward: true));
controller.Update(0.01f, new MovementInput());
controller.BeginMouseLook(new MovementInput(Run: true));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
Assert.True(press.IsRunning);
Assert.Equal(HoldKey.Run, wire.CurrentHoldKey);
Assert.Equal(HoldKey.Invalid, wire.ForwardHoldKey);
Assert.Equal(HoldKey.Invalid, wire.SidestepHoldKey);
Assert.Equal(HoldKey.Invalid, wire.TurnHoldKey);
}
[Fact]
public void MouseLookEntry_ServerControlledHeldTurnNeverQueuesWrongAxis()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput(TurnLeft: true));
Assert.DoesNotContain(
controller.Motion.PendingMotions,
node => node.Motion == MotionCommand.TurnLeft);
Assert.Equal(MotionCommand.SideStepLeft,
controller.Motion.RawState.SidestepCommand);
}
[Fact]
public void MouseLookExit_ServerControlledHeldTurnNeverQueuesRemappedAxis()
{
var controller = CreateController();
var held = new MovementInput(TurnLeft: true);
controller.BeginMouseLook(held);
DrainPendingMotions(controller);
controller.SetLastMoveWasAutonomous(false);
controller.EndMouseLook(held);
Assert.DoesNotContain(
controller.Motion.PendingMotions,
node => node.Motion == MotionCommand.SideStepLeft);
Assert.Equal(MotionCommand.TurnLeft, controller.Motion.RawState.TurnCommand);
}
[Fact]
public void MouseLook_RemapPressedAfterEntryAppearsInOutboundResult()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
MovementResult result = controller.Update(
0.01f,
new MovementInput(TurnLeft: true));
Assert.Equal(MotionCommand.SideStepLeft, result.SidestepCommand);
Assert.Null(result.TurnCommand);
Assert.True(result.SidestepUsesRunHold);
Assert.False(result.IsRunning);
Assert.True(result.ShouldSendMovementEvent);
}
[Fact]
public void MouseLook_WalkModeRemapCarriesPerAxisRunHold()
{
var controller = CreateController();
var held = new MovementInput(TurnRight: true, Run: false);
controller.BeginMouseLook(held);
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press);
Assert.Equal(MotionCommand.SideStepRight, press.SidestepCommand);
Assert.True(press.SidestepUsesRunHold);
Assert.False(press.IsRunning);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.SidestepHoldKey);
Assert.Equal(HoldKey.None, wire.CurrentHoldKey);
Assert.Equal(HoldKey.Run, wire.SidestepHoldKey);
}
[Fact]
public void MouseLook_MouseHandlerRetakesServerControlWithoutDroppingHeldInput()
{
var controller = CreateController();
var held = new MovementInput(Forward: true, TurnLeft: true, Run: true);
controller.BeginMouseLook(held);
controller.SetLastMoveWasAutonomous(false);
controller.SubmitMouseTurnAdjustment(-0.4f, held);
MovementResult result = controller.Update(0.01f, held);
Assert.Equal(MotionCommand.WalkForward, result.ForwardCommand);
Assert.Equal(MotionCommand.SideStepLeft, result.SidestepCommand);
Assert.Equal(MotionCommand.TurnRight, result.TurnCommand);
Assert.True(result.IsRunning);
Assert.True(result.SidestepUsesRunHold);
Assert.True(result.TurnUsesRunHold);
}
[Fact]
public void MouseLookRelease_UsesCurrentInputSnapshotInsteadOfPreviousFrame()
{
var controller = CreateController();
var held = new MovementInput(Forward: true, TurnLeft: true, Run: true);
controller.Update(0.01f, held);
controller.BeginMouseLook(held);
Assert.True(controller.EndMouseLook(new MovementInput()));
MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(release.ForwardCommand);
Assert.Null(release.SidestepCommand);
Assert.Null(release.TurnCommand);
Assert.False(release.IsRunning);
}
[Fact]
public void MouseLook_CannotBeginInPortalSpace()
{
var controller = CreateController();
controller.State = PlayerState.PortalSpace;
Assert.False(controller.BeginMouseLook(new MovementInput(Forward: true)));
MovementResult result = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Null(result.ForwardCommand);
Assert.Null(result.SidestepCommand);
Assert.Null(result.TurnCommand);
}
[Fact]
public void AttackPreparation_StopsMouseTurnAndPublishesFinalHeading()
{
var controller = CreateController();
controller.BeginMouseLook(new MovementInput());
controller.Update(0.01f, new MovementInput());
controller.SubmitMouseTurnAdjustment(-0.5f, new MovementInput());
MovementResult turning = controller.Update(1f / 60f, new MovementInput());
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
Assert.True(controller.PrepareForAttackRequest());
float finalYaw = controller.Yaw;
MovementResult stopped = controller.CaptureMovementResult(mouseLookEvent: false);
Assert.Equal(finalYaw, controller.Yaw);
Assert.Null(stopped.TurnCommand);
Assert.True(stopped.ShouldSendMovementEvent);
}
[Fact]
public void AttackPreparation_DoesNotInterruptServerControlledMovement()
{
var controller = CreateController();
controller.SetLastMoveWasAutonomous(false);
Assert.False(controller.PrepareForAttackRequest());
MovementResult result = controller.Update(
0.01f,
new MovementInput(Run: true));
Assert.False(result.ShouldSendMovementEvent);
}
[Fact]
public void AttackPreparation_DefaultServerControlledStateIsNoOp()
{
var controller = CreateController();
Assert.False(controller.PrepareForAttackRequest());
}
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i;
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
return controller;
}
private static void DrainPendingMotions(PlayerMovementController controller)
{
int remaining = controller.Motion.PendingMotions.Count();
for (int i = 0; i < remaining; i++)
controller.Motion.MotionDone(0u, success: true);
}
}

View file

@ -0,0 +1,196 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.App.Tests.Physics;
public sealed class PlayerOutboundPositionTests
{
private static readonly Plane GroundPlane =
new(Vector3.UnitZ, 0f);
[Fact]
public void CrossLandblockWorldFrame_SendsCarriedCellLocalPosition()
{
var controller = new PlayerMovementController(new PhysicsEngine());
// The render origin remains A9B1, so a point 22.56 m into A9B2 is
// represented at world Y=214.56. The wire frame must remain the
// carried A9B2-local 22.56, never the render-space 214.56.
controller.SetPosition(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));
Assert.True(controller.TryGetOutboundPosition(
Quaternion.Identity,
out uint cellId,
out Vector3 localOrigin));
Assert.Equal(0xA9B20021u, cellId);
Assert.Equal(new Vector3(116.07f, 22.56f, 83.76f), localOrigin);
Assert.NotEqual(controller.Position, localOrigin);
}
[Fact]
public void UnplacedBody_DoesNotProduceWirePosition()
{
var controller = new PlayerMovementController(new PhysicsEngine());
Assert.False(controller.TryGetOutboundPosition(
Quaternion.Identity,
out _,
out _));
}
[Fact]
public void PositionEvent_FirstFrameIsDue()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var current = PositionAt(Quaternion.Identity);
Assert.True(controller.ShouldSendPositionEvent(current, GroundPlane, 0f));
}
[Fact]
public void PositionEvent_StationaryHeadingChangeIsDueAfterRetailInterval()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
var turned = PositionAt(Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f));
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
Assert.False(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
PlayerMovementController.HeartbeatInterval));
Assert.True(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
PlayerMovementController.HeartbeatInterval + 0.01f));
}
[Fact]
public void PositionEvent_UnchangedCompleteFrameIsNotIdleHeartbeat()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
Assert.False(controller.ShouldSendPositionEvent(
sent,
GroundPlane,
PlayerMovementController.HeartbeatInterval + 0.01f));
}
[Fact]
public void MovementEvent_StampsOnlyTime_NotTheRememberedFrame()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
var turned = PositionAt(Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f));
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
controller.NoteMovementSent(nowSeconds: 0.9f);
Assert.False(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
nowSeconds: 1.1f));
Assert.True(controller.ShouldSendPositionEvent(
turned,
GroundPlane,
nowSeconds: 1.91f));
}
[Fact]
public void PositionEvent_CellOrContactPlaneChangeIsDueInsideInterval()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
var otherCell = sent with { ObjCellId = 0xA9B20022u };
var otherPlane = new Plane(Vector3.Normalize(new Vector3(0.01f, 0f, 1f)), 0f);
Assert.True(controller.ShouldSendPositionEvent(otherCell, GroundPlane, 0.1f));
Assert.True(controller.ShouldSendPositionEvent(sent, otherPlane, 0.1f));
}
[Fact]
public void PositionEvent_QuaternionSignIsStoredFrameDataLikeRetail()
{
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
var oppositeStoredQuaternion = PositionAt(new Quaternion(0f, 0f, 0f, -1f));
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
Assert.True(controller.ShouldSendPositionEvent(
oppositeStoredQuaternion,
GroundPlane,
PlayerMovementController.HeartbeatInterval + 0.01f));
}
[Fact]
public void PositionEvent_RetailEpsilonUsesDifferentOriginAndQuaternionBoundaries()
{
const float epsilon = 0.000199999995f;
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = new Position(0xA9B20021u, Vector3.Zero, Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
var exactOriginBoundary = sent with
{
Frame = sent.Frame with { Origin = new Vector3(epsilon, 0f, 0f) },
};
var exactQuaternionBoundary = sent with
{
Frame = sent.Frame with
{
Orientation = new Quaternion(epsilon, 0f, 0f, 1f),
},
};
float elapsed = PlayerMovementController.HeartbeatInterval + 0.01f;
Assert.False(controller.ShouldSendPositionEvent(
exactOriginBoundary,
GroundPlane,
elapsed));
Assert.True(controller.ShouldSendPositionEvent(
exactQuaternionBoundary,
GroundPlane,
elapsed));
}
[Fact]
public void PositionEvent_RetailPlaneDistanceBoundaryIsStrict()
{
const float epsilon = 0.000199999995f;
var controller = new PlayerMovementController(new PhysicsEngine());
var sent = PositionAt(Quaternion.Identity);
controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f);
var exactNormalBoundary = new Plane(
new Vector3(epsilon, 0f, 1f),
0f);
var exactDistanceBoundary = new Plane(Vector3.UnitZ, epsilon);
Assert.False(controller.ShouldSendPositionEvent(
sent,
exactNormalBoundary,
nowSeconds: 0.1f));
Assert.True(controller.ShouldSendPositionEvent(
sent,
exactDistanceBoundary,
nowSeconds: 0.1f));
}
private static Position PositionAt(Quaternion orientation)
=> new(
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f),
orientation);
}

View file

@ -468,6 +468,37 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
}
[Fact]
public void PreviouslyWieldedBow_AfterAuthoritativeUnwield_DoubleClickWieldsAgain()
{
var h = new Harness();
const uint bow = 0x50000B03u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = bow,
Name = "Bow",
Type = ItemType.MissileWeapon,
CombatUse = 1,
ValidLocations = EquipMask.MissileWeapon,
ContainerId = Player,
WielderId = Player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
// Retail ServerSaysMoveItem clears _wielderID when this bow leaves
// the paper doll. This is the login-existing-bow regression: leaving
// the old player id here makes DetermineUseResult say "already wielded."
Assert.True(h.Objects.ApplyServerMove(
bow,
Pack,
newWielderId: 0u,
newSlot: 0));
Assert.True(h.Controller.ActivateItem(bow));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
[Fact]
public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer()
{
@ -534,10 +565,12 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Objects.WieldItemOptimistic(
bow, Player, EquipMask.MissileWeapon));
// Authoritative WieldObject + ConfirmWield completes AutoWield even
// Authoritative WieldObject completes AutoWield even
// though the object table still has another rollback record.
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
h.Objects.ConfirmWield(bow);
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow,
Player,
EquipMask.MissileWeapon));
h.Now += 200;
Assert.True(h.Controller.ActivateItem(sword));
Assert.Equal(

View file

@ -440,6 +440,29 @@ public class PaperdollControllerTests
Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm
}
[Fact]
public void Confirmed_unwield_to_side_bag_clears_slot_from_old_wielder_state()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
const uint helm = 0xF03u, sideBag = 0x50000020u;
Bind(layout, objects);
objects.AddOrUpdate(new ClientObject { ObjectId = helm });
Assert.True(objects.ApplyServerMove(
helm,
newContainerId: 0u,
newWielderId: Player,
newEquipLocation: EquipMask.HeadWear));
Assert.Equal(helm, lists[HeadSlot].Cell.ItemId);
Assert.True(objects.ApplyServerMove(
helm,
newContainerId: sideBag,
newWielderId: 0u));
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
}
[Fact]
public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks
{

View file

@ -477,9 +477,17 @@ public class ToolbarControllerTests
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.AddOrUpdate(new ClientObject { ObjectId = bow, StackSize = 1, StackSizeMax = 1 });
repo.MoveItem(bow, player, newEquipLocation: EquipMask.MissileWeapon);
repo.ApplyServerMove(
bow,
newContainerId: 0u,
newWielderId: player,
newEquipLocation: EquipMask.MissileWeapon);
repo.AddOrUpdate(new ClientObject { ObjectId = arrows, StackSize = 42, StackSizeMax = 100 });
repo.MoveItem(arrows, player, newEquipLocation: EquipMask.MissileAmmo);
repo.ApplyServerMove(
arrows,
newContainerId: 0u,
newWielderId: player,
newEquipLocation: EquipMask.MissileAmmo);
ToolbarController.Bind(
layout,
@ -492,7 +500,7 @@ public class ToolbarControllerTests
Assert.Equal("42", ammoButton.Label);
Assert.True(repo.MoveItem(arrows, pack));
Assert.True(repo.ApplyServerMove(arrows, pack, newWielderId: 0u));
Assert.Null(ammoButton.Label);
}

View file

@ -104,13 +104,13 @@ public sealed class GameEventWiringTests
[Fact]
public void WireAll_WieldObject_RoutesToClientObjectTable()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
var (d, items, _, _, _) = MakeAll(() => player);
items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 });
byte[] payload = new byte[12];
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value);
@ -118,7 +118,8 @@ public sealed class GameEventWiringTests
var item = items.Get(0x1000);
Assert.NotNull(item);
Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
Assert.Equal(0x2000u, item.ContainerId);
Assert.Equal(0u, item.ContainerId);
Assert.Equal(player, item.WielderId);
}
[Fact]
@ -130,7 +131,7 @@ public sealed class GameEventWiringTests
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
var wieldConfirmed = new List<uint>();
items.WieldConfirmed += item => wieldConfirmed.Add(item.ObjectId);
items.WieldConfirmed += itemId => wieldConfirmed.Add(itemId);
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
@ -139,7 +140,8 @@ public sealed class GameEventWiringTests
d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
Assert.Equal(player, items.Get(0x1500)!.ContainerId);
Assert.Equal(0u, items.Get(0x1500)!.ContainerId);
Assert.Equal(player, items.Get(0x1500)!.WielderId);
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
}
@ -567,6 +569,7 @@ public sealed class GameEventWiringTests
const uint playerGuid = 0x50000001u;
const uint packItemGuid = 0x50000A01u;
const uint crossbowGuid = 0x50000B01u;
const uint ammoGuid = 0x50000B02u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
GameEventWiring.WireAll(
@ -591,10 +594,13 @@ public sealed class GameEventWiringTests
w.Write(0u); // spellbook_filters
w.Write(1u); // inventory count
w.Write(packItemGuid); w.Write(0u);
w.Write(1u); // equipped count
w.Write(2u); // equipped count
w.Write(crossbowGuid);
w.Write((uint)EquipMask.MissileWeapon);
w.Write(7u); // layering priority
w.Write(ammoGuid);
w.Write((uint)EquipMask.MissileAmmo);
w.Write(8u);
var envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
@ -603,17 +609,19 @@ public sealed class GameEventWiringTests
ClientObject crossbow = items.Get(crossbowGuid)!;
crossbow.Type = ItemType.MissileWeapon;
crossbow.CombatUse = 2;
var orderedPlayerContents = items.GetContents(playerGuid)
.Select(guid => items.Get(guid)!)
.ToList();
IReadOnlyList<ClientObject> orderedEquipment = items.GetEquippedBy(playerGuid);
Assert.Equal(new[] { packItemGuid, crossbowGuid }, items.GetContents(playerGuid));
Assert.Equal(playerGuid, crossbow.ContainerId);
Assert.Equal(new[] { packItemGuid }, items.GetContents(playerGuid));
Assert.Equal(
new[] { crossbowGuid, ammoGuid },
orderedEquipment.Select(item => item.ObjectId));
Assert.Equal(0u, crossbow.ContainerId);
Assert.Equal(playerGuid, crossbow.WielderId);
Assert.Equal(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation);
Assert.Equal(7u, crossbow.Priority);
Assert.Equal(
CombatMode.Missile,
CombatInputPlanner.GetDefaultCombatMode(orderedPlayerContents));
CombatInputPlanner.GetDefaultCombatMode(orderedEquipment));
}
[Fact]
@ -879,8 +887,8 @@ public sealed class GameEventWiringTests
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value);
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0u, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
@ -959,6 +967,37 @@ public sealed class GameEventWiringTests
Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint);
}
[Fact]
public void WireAll_InventoryPutObjInContainer_ClearsPriorWielder()
{
const uint player = 0x50000001u;
const uint pack = 0x500000C1u;
const uint bow = 0x50000B03u;
var (d, items, _, _, _) = MakeAll(() => player);
items.AddOrUpdate(new ClientObject
{
ObjectId = bow,
ContainerId = player,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), bow);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), pack);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.InventoryPutObjInContainer,
payload));
d.Dispatch(env!.Value);
Assert.Equal(pack, items.Get(bow)!.ContainerId);
Assert.Equal(0u, items.Get(bow)!.WielderId);
Assert.Equal(EquipMask.None, items.Get(bow)!.CurrentlyEquippedLocation);
}
[Fact]
public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
{

View file

@ -41,16 +41,14 @@ public sealed class AppraiseTests
[Fact]
public void ParseWieldObject_RoundTrip()
{
byte[] payload = new byte[12];
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x00400000u); // MeleeWeapon slot
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000u); // wielder
var parsed = GameEvents.ParseWieldObject(payload);
Assert.NotNull(parsed);
Assert.Equal(0x1000u, parsed!.Value.ItemGuid);
Assert.Equal(0x00400000u, parsed.Value.EquipLoc);
Assert.Equal(0x2000u, parsed.Value.WielderGuid);
}
[Fact]

View file

@ -207,7 +207,6 @@ public sealed class GameEventDispatcherTests
Assert.NotNull(parsed);
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc);
Assert.Equal(0u, parsed.Value.WielderGuid);
}
[Fact]

View file

@ -100,6 +100,7 @@ public sealed class WeenieErrorMessagesTests
[InlineData(0x0036u, "Action cancelled!")]
[InlineData(0x003Du, "You charged too far!")]
[InlineData(0x004Au, "Ack! You killed yourself!")]
[InlineData(0x0550u, "Out of Range!")]
public void Format_CombatMovementErrors(uint code, string expected)
=> Assert.Equal(expected, WeenieErrorMessages.Format(code, null));

View file

@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
@ -27,7 +28,9 @@ namespace AcDream.Core.Tests.Input;
public class PlayerMoveToCutoverTests
{
private const uint NC = 0x8000003Du;
private const uint MissileCombat = 0x8000003Fu;
private const uint Ready = 0x41000003u;
private const uint Reload = 0x40000016u;
private const uint Walk = 0x45000005u;
private const uint Run = 0x44000007u;
private const uint TurnRight = 0x6500000Du;
@ -73,7 +76,10 @@ public class PlayerMoveToCutoverTests
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
mt.StyleDefaults[(DRWMotionCommand)MissileCombat] = (DRWMotionCommand)Ready;
mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
mt.Cycles[(int)((MissileCombat << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
mt.Cycles[(int)((MissileCombat << 16) | (Reload & 0xFFFFFFu))] = MakeMd(0x301u);
mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u);
mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u);
// The MoveToManager's aux-turn steering / TurnToHeading nodes
@ -82,6 +88,7 @@ public class PlayerMoveToCutoverTests
// _DoMotion error and the manager cancels the moveto (retail
// dispatch-failure semantics), so the fixture needs one.
mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
mt.Cycles[(int)((MissileCombat << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
return new AnimationSequencer(setup, mt, loader);
}
@ -102,8 +109,27 @@ public class PlayerMoveToCutoverTests
{
public required PlayerMovementController Controller;
public required MoveToManager MoveTo;
public required EntityPhysicsHost PlayerHost;
public required Dictionary<uint, IPhysicsObjHost> Hosts;
public int MoveToCompleteCount;
public WeenieError LastCompleteError;
public void AddStaticTarget(uint guid, Position position)
{
var target = new EntityPhysicsHost(
guid,
getPosition: () => position,
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: () => Controller.SimTimeSeconds,
physicsTimerTime: () => Controller.SimTimeSeconds,
getObjectA: id => Hosts.TryGetValue(id, out var host) ? host : null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
Hosts[guid] = target;
}
}
/// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink,
@ -116,6 +142,12 @@ public class PlayerMoveToCutoverTests
var controller = new PlayerMovementController(MakeFlatEngine());
var seq = MakeSequencer();
seqOut = seq;
// Production AnimatedEntity wiring installs the completion callback
// before EnterPlayerModeNow binds the sink and calls SetPosition.
// A synchronous zero-tick completion at the CPhysicsObj movement
// boundary must therefore reach MotionInterpreter even during the
// initial placement stop.
seq.MotionDoneTarget = (m, ok) => controller.Motion.MotionDone(m, ok);
// Production (EnterPlayerModeNow) order: the sink binds BEFORE the
// initial SetPosition — SetPosition → StopCompletely needs the sink
// for its type-5 motion-table dispatch, the completable partner of
@ -130,10 +162,19 @@ public class PlayerMoveToCutoverTests
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // heading 90 = facing +X
var rig = new Rig { Controller = controller, MoveTo = null! };
const uint selfGuid = 0x5000000Au;
var hosts = new Dictionary<uint, IPhysicsObjHost>();
EntityPhysicsHost playerHost = null!;
var rig = new Rig
{
Controller = controller,
MoveTo = null!,
PlayerHost = null!,
Hosts = hosts,
};
var moveTo = new MoveToManager(
controller.Motion,
stopCompletely: () => controller.Motion.StopCompletely(),
stopCompletely: () => controller.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => new Position(
controller.CellId, controller.Position, controller.BodyOrientation),
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
@ -143,12 +184,28 @@ public class PlayerMoveToCutoverTests
contact: () => controller.BodyInContact,
isInterpolating: () => false,
getVelocity: () => controller.BodyVelocity,
getSelfId: () => 0x5000000Au,
setTarget: (_, _, _, _) => { },
clearTarget: () => { },
getTargetQuantum: () => 0.0,
setTargetQuantum: _ => { },
getSelfId: () => selfGuid,
setTarget: (ctx, id, radius, quantum) =>
playerHost.SetTarget(ctx, id, radius, quantum),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: quantum => playerHost.TargetManager.SetTargetQuantum(quantum),
curTime: () => controller.SimTimeSeconds);
playerHost = new EntityPhysicsHost(
selfGuid,
getPosition: () => new Position(
controller.CellId, controller.Position, controller.BodyOrientation),
getVelocity: () => controller.BodyVelocity,
getRadius: () => 0.5f,
inContact: () => controller.BodyInContact,
minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(),
curTime: () => controller.SimTimeSeconds,
physicsTimerTime: () => controller.SimTimeSeconds,
getObjectA: id => hosts.TryGetValue(id, out var host) ? host : null,
handleUpdateTarget: info => moveTo.HandleUpdateTarget(info),
interruptCurrentMovement: () =>
moveTo.CancelMoveTo(WeenieError.ActionCancelled));
hosts[selfGuid] = playerHost;
moveTo.MoveToComplete = err =>
{
rig.MoveToCompleteCount++;
@ -158,6 +215,7 @@ public class PlayerMoveToCutoverTests
controller.Motion.InterruptCurrentMovement =
() => moveTo.CancelMoveTo(WeenieError.ActionCancelled);
rig.MoveTo = moveTo;
rig.PlayerHost = playerHost;
return rig;
}
@ -290,8 +348,6 @@ public class PlayerMoveToCutoverTests
// under the production completion feed.
var rig = MakeRig(out var seq);
var c = rig.Controller;
seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok);
for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++)
{
c.Update(1f / 60f, new MovementInput());
@ -317,7 +373,6 @@ public class PlayerMoveToCutoverTests
// pops; remotes 0/40).
var rig = MakeRig(out var seq);
var c = rig.Controller;
seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok);
var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
// A few idle frames first — production always has render ticks
@ -355,6 +410,130 @@ public class PlayerMoveToCutoverTests
Assert.Equal(1, rig.MoveToCompleteCount);
}
[Fact]
public void ServerTurnToObject_AfterMissileReload_ProductionFeed_RotatesAndCompletes()
{
// Live bow repro (2026-07-15): ACE broadcasts MissileCombat/Reload,
// then a type-8 TurnToObject carrying StopCompletely before it
// launches the arrow. After target resolution, retail's object turn
// is exactly one TurnToHeading node, so this pins the animation-queue
// boundary without replacing TargetManager with a test-only poll.
var rig = MakeRig(out var seq);
var c = rig.Controller;
for (int f = 0; f < 120; f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
Assert.Equal(WeenieError.None, c.Motion.DoMotion(
MissileCombat, new MovementParameters()));
Assert.Equal(WeenieError.None, c.Motion.DoInterpretedMotion(
Reload, new MovementParameters { Speed = 2f }));
const uint targetGuid = 0x80001234u;
rig.AddStaticTarget(targetGuid, new Position(
c.CellId, new Vector3(88f, 96f, 50f), Quaternion.Identity));
rig.MoveTo.PerformMovement(new MovementStruct
{
Type = MovementType.TurnToObject,
ObjectId = targetGuid,
TopLevelId = targetGuid,
Params = new MovementParameters
{
StopCompletelyFlag = true,
Speed = 1f,
},
});
float initialYaw = c.Yaw;
for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
string interpQueue = string.Join(", ",
c.Motion.PendingMotions.Select(n => $"0x{n.Motion:X8}"));
string animationQueue = string.Join(", ",
seq.Manager.PendingAnimations.Select(n => $"0x{n.Motion:X8}/{n.NumAnims}"));
Assert.True(initialYaw != c.Yaw,
$"turn never changed yaw; moving={rig.MoveTo.IsMovingTo()} "
+ $"complete={rig.MoveToCompleteCount} error={rig.LastCompleteError} "
+ $"interp=[{interpQueue}] animation=[{animationQueue}]");
Assert.False(rig.MoveTo.IsMovingTo());
Assert.Equal(1, rig.MoveToCompleteCount);
Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 269.99f, 270.01f);
Assert.False(c.Motion.MotionsPending());
}
[Fact]
public void KeyboardTurnRelease_ThenAttackStop_DrainsBeforeServerTurnToObject()
{
// Live bow repro (2026-07-15): turn the character away with a player
// input edge, release the key, begin an attack (MaybeStopCompletely),
// then receive ACE's type-8 TurnToObject. Retail routes the input
// DoMotion/StopMotion calls through CPhysicsObj -> MovementManager,
// so every edge runs the zero-tick completion sweep and leaves no
// orphan that can starve BeginTurnToHeading.
var rig = MakeRig(out var seq);
var c = rig.Controller;
for (int f = 0; f < 90; f++)
{
c.Update(1f / 60f, new MovementInput { Run = true, TurnRight = true });
seq.Advance(1f / 60f);
}
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
for (int f = 0; f < 120 && c.Motion.MotionsPending(); f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
Assert.False(c.Motion.MotionsPending(),
"keyboard turn release must leave no interpreter queue orphan");
Assert.Empty(seq.Manager.PendingAnimations);
Assert.True(c.PrepareForAttackRequest());
Assert.False(c.Motion.MotionsPending(),
"attack-entry StopCompletely must synchronously drain zero-tick Ready");
Assert.Empty(seq.Manager.PendingAnimations);
const uint targetGuid = 0x80004321u;
rig.AddStaticTarget(targetGuid, new Position(
c.CellId, c.Position + new Vector3(12f, 0f, 0f), Quaternion.Identity));
float initialYaw = c.Yaw;
rig.Controller.SetLastMoveWasAutonomous(false);
rig.MoveTo.PerformMovement(new MovementStruct
{
Type = MovementType.TurnToObject,
ObjectId = targetGuid,
TopLevelId = targetGuid,
Params = new MovementParameters
{
StopCompletelyFlag = true,
Speed = 1f,
},
});
for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++)
{
c.Update(1f / 60f, new MovementInput { Run = true });
seq.Advance(1f / 60f);
}
Assert.NotEqual(initialYaw, c.Yaw);
Assert.False(rig.MoveTo.IsMovingTo());
Assert.Equal(1, rig.MoveToCompleteCount);
Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 89.99f, 90.01f);
Assert.False(c.Motion.MotionsPending());
Assert.Empty(seq.Manager.PendingAnimations);
}
[Fact]
public void JumpRelease_CancelsMoveTo()
{

View file

@ -54,7 +54,11 @@ public sealed class ClientObjectTableTests
repo.AddOrUpdate(item);
uint seenOld = 999, seenNew = 999;
repo.ObjectMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; };
repo.ObjectMoved += move =>
{
seenOld = move.Previous.ContainerId;
seenNew = move.Current.ContainerId;
};
repo.MoveItem(100, 42, newSlot: 3);
@ -71,6 +75,74 @@ public sealed class ClientObjectTableTests
Assert.False(repo.MoveItem(999, 42));
}
[Fact]
public void ApplyServerMove_PublishesCompleteOldAndNewRetailPlacements()
{
var repo = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u, itemId = 101u;
repo.AddOrUpdate(new ClientObject
{
ObjectId = itemId,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
ClientObjectMove? seen = null;
repo.ObjectMoved += move => seen = move;
Assert.True(repo.ApplyServerMove(itemId, pack, 0u, 3));
Assert.Equal(
new ClientObjectPlacement(0u, -1, player, EquipMask.MissileWeapon),
seen!.Value.Previous);
Assert.Equal(
new ClientObjectPlacement(pack, 3, 0u, EquipMask.None),
seen.Value.Current);
}
[Fact]
public void ConfirmedUnknownMove_PublishesGuidAndRawPlacement()
{
var repo = new ClientObjectTable();
ClientObjectMove? seen = null;
repo.ObjectMoved += move => seen = move;
Assert.False(repo.ApplyConfirmedServerMove(
0xDEADu,
0x40000001u,
newWielderId: 0u,
newSlot: 7));
Assert.Equal(0xDEADu, seen!.Value.ItemId);
Assert.Null(seen.Value.Item);
Assert.Equal(default, seen.Value.Previous);
Assert.Equal(
new ClientObjectPlacement(0x40000001u, 7, 0u, EquipMask.None),
seen.Value.Current);
}
[Fact]
public void ConfirmedUnknownWield_PublishesGuidPlacementAndCompletion()
{
var repo = new ClientObjectTable();
const uint itemId = 0xBEEFu, player = 0x50000001u;
ClientObjectMove? seen = null;
uint completed = 0u;
repo.ObjectMoved += move => seen = move;
repo.WieldConfirmed += id => completed = id;
Assert.False(repo.ApplyConfirmedServerWield(
itemId,
player,
EquipMask.MissileWeapon));
Assert.Equal(itemId, seen!.Value.ItemId);
Assert.Null(seen.Value.Item);
Assert.Equal(
new ClientObjectPlacement(0u, 0, player, EquipMask.MissileWeapon),
seen.Value.Current);
Assert.Equal(itemId, completed);
}
[Fact]
public void Remove_FiresEventAndRemoves()
{
@ -471,53 +543,156 @@ public sealed class ClientObjectTableTests
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId);
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId);
}
[Fact]
public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge)
public void ReplaceContents_PublishesListReplacementWithoutSyntheticMove()
{
var table = new ClientObjectTable();
int moves = 0;
uint replaced = 0u;
table.ObjectMoved += _ => moves++;
table.ContainerContentsReplaced += container => replaced = container;
table.ReplaceContents(0xC9u, new uint[] { 0xA01u });
Assert.Equal(0, moves);
Assert.Equal(0xC9u, replaced);
}
[Fact]
public void ProjectionOnlyMember_AuthoritativeMoveEvictsStaleViewedList()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, pack = 0x50000001u, item = 0xA20u;
table.ReplaceContents(chest, new uint[] { item });
Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 0));
Assert.Empty(table.GetContents(chest));
Assert.Equal(new[] { item }, table.GetContents(pack));
}
[Fact]
public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, packA = 0x50000001u;
const uint packB = 0x50000002u, item = 0xA22u;
table.ReplaceContents(chest, new uint[] { item });
table.ContainerContentsReplaced += containerId =>
{
if (containerId == chest)
Assert.True(table.ApplyServerMove(item, packB, newWielderId: 0u, newSlot: 0));
};
Assert.True(table.ApplyServerMove(item, packA, newWielderId: 0u, newSlot: 0));
Assert.Empty(table.GetContents(chest));
Assert.Empty(table.GetContents(packA));
Assert.Equal(new[] { item }, table.GetContents(packB));
Assert.Equal(packB, table.Get(item)!.ContainerId);
}
[Fact]
public void ProjectionOnlyMember_DeleteEvictsStaleViewedListAndNotifies()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, item = 0xA21u;
table.ReplaceContents(chest, new uint[] { item });
int replacements = 0;
table.ContainerContentsReplaced += id =>
{
if (id == chest) replacements++;
};
Assert.True(table.Remove(item));
Assert.Empty(table.GetContents(chest));
Assert.Equal(1, replacements);
}
[Fact]
public void DeleteProjectionNotification_GuidReuseSurvivesCompletedTeardown()
{
var table = new ClientObjectTable();
const uint chest = 0x40000001u, item = 0xA23u, player = 0x50000001u;
table.ReplaceContents(chest, new uint[] { item });
table.ContainerContentsReplaced += containerId =>
{
if (containerId != chest) return;
table.AddOrUpdate(new ClientObject
{
ObjectId = item,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
};
Assert.True(table.Remove(item));
Assert.NotNull(table.Get(item));
Assert.Equal(
new[] { item },
table.GetEquippedBy(player).Select(equipped => equipped.ObjectId));
}
[Fact]
public void ReplaceContents_SecondSmallerList_ReplacesProjectionOnly()
{
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u));
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId);
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
Assert.Equal(0u, table.Get(0xA02u)!.ContainerId);
}
[Fact]
public void ReplaceContents_listedMemberClearsStaleEquipLocation()
public void ReplaceContents_listedMemberPreservesCanonicalPlacement()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject
{
ObjectId = 0xA10u,
ContainerId = 0x50000001u,
ContainerId = 0u,
WielderId = 0x50000001u,
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
});
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
var item = table.Get(0xA10u)!;
Assert.Equal(0x50000001u, item.ContainerId);
Assert.Equal(0, item.ContainerSlot);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Equal(0u, item.ContainerId);
Assert.Equal(0x50000001u, item.WielderId);
Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(
EquipMask.ChestWear | EquipMask.UpperArmWear,
item.CurrentlyEquippedLocation);
}
[Fact]
public void ReplaceContents_detachedMemberClearsStaleEquipLocation()
public void ReplaceContents_omittedMemberPreservesCanonicalPlacement()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u });
table.AddOrUpdate(new ClientObject
{
ObjectId = 0xA11u,
WielderId = 0x50000001u,
});
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
var item = table.Get(0xA11u)!;
Assert.Equal(0u, item.ContainerId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Equal(0x50000001u, item.ContainerId);
Assert.Equal(0x50000001u, item.WielderId);
Assert.Equal(
EquipMask.ChestWear | EquipMask.UpperArmWear,
item.CurrentlyEquippedLocation);
}
[Fact]
@ -559,11 +734,130 @@ public sealed class ClientObjectTableTests
table.Ingest(FullWeenie(0xA01u, container: player));
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
Assert.Equal(-1, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(-1, table.Get(0xA02u)!.ContainerSlot);
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
}
[Fact]
public void InitializeInventoryManifest_SeedsCanonicalOwnershipAndOrder()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(0xA01u, 0u),
new ContainerContentEntry(0xA02u, 1u),
});
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
Assert.Equal(player, table.Get(0xA01u)!.ContainerId);
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(player, table.Get(0xA02u)!.ContainerId);
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
}
[Fact]
public void InitializeEquipmentManifest_PreservesPackedOrderAndCanonicalOwnership()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.InitializeEquipmentManifest(player, new[]
{
new EquipmentManifestEntry(0xA11u, EquipMask.MeleeWeapon, 1u),
new EquipmentManifestEntry(0xA12u, EquipMask.MissileAmmo, 2u),
});
Assert.Equal(
new[] { 0xA11u, 0xA12u },
table.GetEquippedBy(player).Select(item => item.ObjectId));
Assert.Equal(0u, table.Get(0xA11u)!.ContainerId);
Assert.Equal(player, table.Get(0xA11u)!.WielderId);
Assert.Empty(table.GetContents(player));
}
[Fact]
public void InitializeEquipmentManifest_ReplacementClearsOmittedCanonicalEquipment()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
const uint weapon = 0xA11u;
table.InitializeEquipmentManifest(player, new[]
{
new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u),
});
table.Get(weapon)!.Burden = 10;
table.InitializeEquipmentManifest(player, Array.Empty<EquipmentManifestEntry>());
ClientObject item = table.Get(weapon)!;
Assert.Equal(0u, item.WielderId);
Assert.Equal(0u, item.ContainerId);
Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Empty(table.GetEquippedBy(player));
Assert.Equal(0, table.SumCarriedBurden(player));
}
[Fact]
public void InitializeInventoryManifest_ReplacementClearsOmittedCanonicalOwnership()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
const uint itemId = 0xA01u;
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(itemId, 0u),
});
table.Get(itemId)!.Burden = 10;
table.InitializeInventoryManifest(player, Array.Empty<ContainerContentEntry>());
ClientObject item = table.Get(itemId)!;
Assert.Equal(0u, item.ContainerId);
Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(0u, item.WielderId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
Assert.Empty(table.GetContents(player));
Assert.Equal(0, table.SumCarriedBurden(player));
}
[Fact]
public void PlayerDescriptionManifests_MoveItemBetweenInventoryAndEquipment()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
const uint weapon = 0xA11u;
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(weapon, 0u),
});
table.InitializeEquipmentManifest(player, new[]
{
new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u),
});
Assert.Empty(table.GetContents(player));
Assert.Equal(player, table.Get(weapon)!.WielderId);
Assert.Equal(EquipMask.MeleeWeapon, table.Get(weapon)!.CurrentlyEquippedLocation);
table.InitializeEquipmentManifest(player, Array.Empty<EquipmentManifestEntry>());
table.InitializeInventoryManifest(player, new[]
{
new ContainerContentEntry(weapon, 0u),
});
Assert.Empty(table.GetEquippedBy(player));
Assert.Equal(new[] { weapon }, table.GetContents(player));
Assert.Equal(player, table.Get(weapon)!.ContainerId);
Assert.Equal(0u, table.Get(weapon)!.WielderId);
}
[Fact]
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
{
@ -692,8 +986,8 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
var o = table.Get(0x940u)!;
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm)
Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model)
Assert.Equal(player, o.ContainerId); // temporary pre-confirm placement projection
Assert.Equal(0u, o.WielderId); // authoritative WieldObject later sets WielderId + clears ContainerId
}
[Fact]
@ -731,6 +1025,89 @@ public sealed class ClientObjectTableTests
public void WieldItemOptimistic_unknownItem_false()
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
[Fact]
public void ApplyServerMove_UpdatesAuthoritativeWielderAcrossWieldAndUnwield()
{
var table = new ClientObjectTable();
const uint item = 0x9430u, player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject
{
ObjectId = item,
ContainerId = player,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 2));
Assert.Equal(pack, table.Get(item)!.ContainerId);
Assert.Equal(0u, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
Assert.True(table.ApplyServerMove(
item,
newContainerId: 0u,
newWielderId: player,
newEquipLocation: EquipMask.MissileWeapon));
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation);
}
[Fact]
public void ApplyConfirmedServerWield_ReentrantMoveKeepsNewPendingRequest()
{
var table = new ClientObjectTable();
const uint item = 0x9431u, player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject { ObjectId = item, ContainerId = pack, ContainerSlot = 0 });
Assert.True(table.WieldItemOptimistic(item, player, EquipMask.MissileWeapon));
bool queuedFromNotification = false;
table.ObjectMoved += move =>
{
if (queuedFromNotification || move.Current.WielderId != player) return;
queuedFromNotification = true;
Assert.True(table.MoveItemOptimistic(item, pack, newSlot: 0));
};
Assert.True(table.ApplyConfirmedServerWield(item, player, EquipMask.MissileWeapon));
Assert.True(queuedFromNotification);
Assert.True(table.RollbackMove(item));
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation);
}
[Fact]
public void GetEquippedBy_PreservesRetailHeadInsertionOrder()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x9432u,
ContainerId = 0u,
WielderId = player,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
});
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x9433u,
ContainerId = player,
WielderId = 0u,
CurrentlyEquippedLocation = EquipMask.MissileAmmo,
});
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x9434u,
ContainerId = player,
CurrentlyEquippedLocation = EquipMask.None,
});
Assert.Equal(
new[] { 0x9433u, 0x9432u },
table.GetEquippedBy(player).Select(item => item.ObjectId));
}
[Fact]
public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure()
{
@ -766,7 +1143,7 @@ public sealed class ClientObjectTableTests
}
[Fact]
public void ConfirmWield_publishesIndependentlyOfRollbackOutstandingCount()
public void ApplyConfirmedServerWield_publishesIndependentlyOfRollbackOutstandingCount()
{
var table = new ClientObjectTable();
const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u;
@ -775,12 +1152,20 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
table.MoveItemOptimistic(item, pack, 0);
var confirmed = new List<uint>();
table.WieldConfirmed += moved => confirmed.Add(moved.ObjectId);
table.WieldConfirmed += itemId => confirmed.Add(itemId);
table.ConfirmWield(item);
Assert.True(table.ApplyConfirmedServerWield(
item,
player,
EquipMask.MeleeWeapon));
Assert.Equal(new[] { item }, confirmed);
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.True(table.RollbackMove(item));
Assert.Equal(pack, table.Get(item)!.ContainerId);
Assert.Equal(0u, table.Get(item)!.WielderId);
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
}
[Fact]

View file

@ -92,6 +92,44 @@ public sealed class MovementManagerTests
Assert.Null(mm.MoveTo);
}
[Fact]
public void PerformMovement_InterpTypes_ForwardOriginalParametersObject()
{
// CMotionInterp::PerformMovement @ 0x00528E80 forwards
// MovementStruct::params verbatim. This is observable at the player
// input boundary: rebuilding the block from MovementStruct's legacy
// scalar fields loses its speed, hold key, and control flags.
var (mm, h, calls) = MakeFacade();
int interrupts = 0;
h.Interp.InterruptCurrentMovement = () => interrupts++;
var parameters = new MovementParameters
{
Speed = 0.37f,
SetHoldKey = true,
HoldKeyToApply = HoldKey.Run,
CancelMoveTo = false,
ModifyRawState = true,
ModifyInterpretedState = true,
};
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.RawCommand,
Motion = MotionCommand.TurnRight,
// Deliberately contradictory legacy fields: Params wins.
Speed = 9f,
ModifyRawState = false,
ModifyInterpretedState = false,
Params = parameters,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(0.37f, h.Interp.RawState.TurnSpeed);
Assert.Equal(HoldKey.Run, h.Interp.RawState.CurrentHoldKey);
Assert.Equal(0, interrupts);
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo()
{

View file

@ -191,7 +191,8 @@ internal sealed class RemoteChaseHarness
{
var mtm = new MoveToManager(
Interp,
stopCompletely: () => Interp.StopCompletely(),
stopCompletely: () => Movement.PerformMovement(
new MovementStruct { Type = MovementType.StopCompletely }),
getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation),
getHeading: () => MoveToMath.GetHeading(Body.Orientation),
setHeading: (h, _) => Body.Orientation =

View file

@ -12,7 +12,7 @@ namespace AcDream.UI.Abstractions.Tests.Input;
/// </summary>
public sealed class MmbMouseLookTests
{
private sealed class YawSink
private sealed class AdjustmentSink
{
public float Total;
public int ApplyCount;
@ -22,7 +22,7 @@ public sealed class MmbMouseLookTests
[Fact]
public void Press_ActivatesAndCapturesCursorPosition()
{
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false);
@ -35,7 +35,7 @@ public sealed class MmbMouseLookTests
[Fact]
public void Release_DeactivatesWhenActive()
{
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
@ -50,7 +50,7 @@ public sealed class MmbMouseLookTests
// Defense in depth — the dispatcher already filters on
// WantCaptureMouse, but if a binding ever fires through the
// state machine itself must not turn on.
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: true);
@ -61,7 +61,7 @@ public sealed class MmbMouseLookTests
[Fact]
public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates()
{
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
Assert.True(ml.Active);
@ -79,7 +79,7 @@ public sealed class MmbMouseLookTests
{
// Going from "ImGui captures" to "ImGui doesn't capture" must
// NOT auto-reactivate — only an explicit Press does.
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.OnWantCaptureMouseChanged(wantCaptureMouse: false);
@ -88,17 +88,17 @@ public sealed class MmbMouseLookTests
}
[Fact]
public void ApplyDelta_WhileActive_DrivesYawSink()
public void ApplyDelta_WhileActive_DrivesMovementAdjustmentSink()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.01f };
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f };
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f);
for (int i = 0; i < 6; i++)
ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f);
// Sign: dragging right (positive dx) yaws the character right
// — by the acdream Yaw convention that's negative yaw delta.
// Magnitude: 10 * 0.01 * 1.0 = 0.1 rad.
// Sign: dragging right (positive dx) requests TurnRight, represented
// by a negative adjustment. Magnitude: 10 * 0.01 * 1.0 = 0.1.
Assert.Equal(1, sink.ApplyCount);
Assert.Equal(-0.1f, sink.Total, 5);
}
@ -106,7 +106,7 @@ public sealed class MmbMouseLookTests
[Fact]
public void ApplyDelta_WhileInactive_DoesNothing()
{
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
// Never pressed.
@ -119,7 +119,7 @@ public sealed class MmbMouseLookTests
[Fact]
public void ApplyDelta_AfterRelease_DoesNothing()
{
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.Release();
@ -135,7 +135,7 @@ public sealed class MmbMouseLookTests
// Repeated Press calls must not blow away the originally
// captured cursor position — the cursor-restore-on-release
// path needs the original anchor.
var sink = new YawSink();
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(100f, 200f, wantCaptureMouse: false);
@ -148,19 +148,82 @@ public sealed class MmbMouseLookTests
[Fact]
public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink()
{
// Combined drive: the test only exposes a single yaw mutator,
// and that's the design — the yaw mutator GameWindow passes
// updates _playerController.Yaw, and the chase camera reads
// the same yaw via ChaseCamera.Update(pos, playerYaw). So a
// single sink in this test models both behaviors.
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.005f };
// The input state owns only filtering/scaling. GameWindow's one sink
// routes the result to PlayerMovementController, whose turn motion
// updates both the character heading and chase-camera follow source.
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.005f };
ml.Press(0f, 0f, wantCaptureMouse: false);
for (int i = 0; i < 5; i++)
ml.ApplyDelta(dx: 4f, extraSensitivity: 0.5f);
ml.ApplyDelta(dx: 4f, extraSensitivity: 0.5f); // -0.01
ml.ApplyDelta(dx: -2f, extraSensitivity: 0.5f); // +0.005
Assert.Equal(2, sink.ApplyCount);
Assert.Equal(-0.005f, sink.Total, 5);
}
[Fact]
public void ApplyDelta_ZeroSampleResetsRetailHorizontalExtent()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f };
ml.Press(0f, 0f, wantCaptureMouse: false);
for (int i = 0; i < 5; i++)
ml.ApplyDelta(dx: 2f, extraSensitivity: 1f);
ml.ApplyDelta(dx: 0f, extraSensitivity: 1f);
for (int i = 0; i < 5; i++)
ml.ApplyDelta(dx: 2f, extraSensitivity: 1f);
Assert.Equal(0, sink.ApplyCount);
}
[Fact]
public void TryTakeRawSample_IdleZeroUsesStrictRetailDelayAndResetsExtent()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f };
ml.Press(0f, 0f, wantCaptureMouse: false, nowSeconds: 1f);
for (int i = 0; i < 5; i++)
{
ml.QueueDelta(2f);
float now = 1.01f + (i * 0.01f);
Assert.True(ml.TryTakeRawSample(now, out float dx, out float dy));
Assert.Equal(0f, dy);
ml.ApplyDelta(dx, extraSensitivity: 1f);
}
// Retail uses a strict > 0.2-second boundary; eventless render frames
// before then do not synthesize input at all.
Assert.False(ml.TryTakeRawSample(1.25f, out _, out _));
Assert.True(ml.TryTakeRawSample(1.2501f, out float idleDx, out float idleDy));
Assert.Equal(0f, idleDx);
Assert.Equal(0f, idleDy);
ml.ApplyDelta(idleDx, extraSensitivity: 1f);
ml.QueueDelta(2f);
Assert.True(ml.TryTakeRawSample(1.26f, out float resumedDx, out _));
ml.ApplyDelta(resumedDx, extraSensitivity: 1f);
Assert.Equal(0, sink.ApplyCount);
}
[Fact]
public void TryTakeRawSample_AccumulatesNativeEventsIntoOneFrameSample()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false, nowSeconds: 2f);
ml.QueueDelta(2f, 1f);
ml.QueueDelta(3f, -0.25f);
Assert.True(ml.TryTakeRawSample(2.01f, out float dx, out float dy));
Assert.Equal(5f, dx);
Assert.Equal(0.75f, dy);
Assert.False(ml.TryTakeRawSample(2.02f, out _, out _));
}
}