acdream/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs
Erik 7b7ffcd278 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>
2026-07-15 08:19:23 +02:00

229 lines
7.5 KiB
C#

using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Phase K.2 — MMB-hold instant mouse-look state machine. While
/// active, mouse-X drives the character's heading (and the chase
/// camera follows automatically because <c>ChaseCamera.Update</c>
/// reads the player yaw). The state machine guards activation on
/// ImGui's <c>WantCaptureMouse</c> so a panel-hovered MMB doesn't
/// hijack the cursor.
/// </summary>
public sealed class MmbMouseLookTests
{
private sealed class AdjustmentSink
{
public float Total;
public int ApplyCount;
public void Apply(float d) { Total += d; ApplyCount++; }
}
[Fact]
public void Press_ActivatesAndCapturesCursorPosition()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false);
Assert.True(ml.Active);
Assert.Equal(320f, ml.CapturedCursorX);
Assert.Equal(240f, ml.CapturedCursorY);
}
[Fact]
public void Release_DeactivatesWhenActive()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.Release();
Assert.False(ml.Active);
}
[Fact]
public void Press_WhileImGuiCapturesMouse_DoesNotActivate()
{
// 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 AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: true);
Assert.False(ml.Active);
}
[Fact]
public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
Assert.True(ml.Active);
// ImGui takes mouse focus mid-hold (e.g. tooltip pop-up over
// the cursor) → suspend mouse-look so the panel gets the
// cursor.
ml.OnWantCaptureMouseChanged(wantCaptureMouse: true);
Assert.False(ml.Active);
}
[Fact]
public void OnWantCaptureMouseChanged_FalseWhileInactive_NoOp()
{
// Going from "ImGui captures" to "ImGui doesn't capture" must
// NOT auto-reactivate — only an explicit Press does.
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.OnWantCaptureMouseChanged(wantCaptureMouse: false);
Assert.False(ml.Active);
}
[Fact]
public void ApplyDelta_WhileActive_DrivesMovementAdjustmentSink()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f };
ml.Press(0f, 0f, wantCaptureMouse: false);
for (int i = 0; i < 6; i++)
ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f);
// 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);
}
[Fact]
public void ApplyDelta_WhileInactive_DoesNothing()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
// Never pressed.
ml.ApplyDelta(dx: 100f, extraSensitivity: 1.0f);
Assert.Equal(0, sink.ApplyCount);
Assert.Equal(0f, sink.Total);
}
[Fact]
public void ApplyDelta_AfterRelease_DoesNothing()
{
var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.Release();
ml.ApplyDelta(dx: 50f, extraSensitivity: 1.0f);
Assert.Equal(0, sink.ApplyCount);
}
[Fact]
public void Press_WhileAlreadyActive_IsIdempotent()
{
// 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 AdjustmentSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(100f, 200f, wantCaptureMouse: false);
ml.Press(999f, 888f, wantCaptureMouse: false);
Assert.Equal(100f, ml.CapturedCursorX);
Assert.Equal(200f, ml.CapturedCursorY);
}
[Fact]
public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink()
{
// 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 _));
}
}