feat(input): #24 Phase K.2 - auto-enter player mode at login + MMB mouse-look + DebugPanel free-fly + Tab to chat-input focus

Five changes:

1. PlayerModeAutoEntry — testable guard class that fires once after
   EnterWorld + WorldSession.State.InWorld + player entity present +
   PlayerController.State == InWorld. GameWindow arms the entry
   after EnterWorld; per-frame Tick checks all four guards and
   invokes the same fly-to-player transition the Tab handler runs.
   User-initiated fly toggle (DebugPanel button) Cancel()s pending
   entry. Skip in offline mode (no ACDREAM_LIVE) — Holtburg orbit
   stays default for testing.

2. MouseLookState + KeyBindings.RetailDefaults() binds MMB Hold to
   InputAction.CameraInstantMouseLook. GameWindow subscribes:
   - Press: hide cursor, capture position, _mouseLookActive = true.
   - Release: restore cursor, deactivate.
   - WantCaptureMouse=true while held → suspend (release cursor).
   - MouseMove while active: combined drive — chase camera yaw +
     character heading move together (retail's signature mouse-look
     behavior). Camera Y still pitches camera-only.

3. DebugPanel "Toggle Free-Fly Mode" button via DebugVM.ToggleFlyMode
   action delegate — replaces the F-key as the primary discovery
   path for free-fly. Gated on DevToolsEnabled.

4. ChatPanel.FocusInput() one-shot + IPanelRenderer.SetKeyboardFocusHere
   primitive. GameWindow's ToggleChatEntry (Tab) subscriber calls
   _chatPanel.FocusInput() so Tab moves focus to the chat input
   field. Replaces the K.1c TODO stub.

5. WantCaptureMouse gating reinforcement on surviving mouse handlers
   (no new code; verified intact from K.1b).

21 new tests (8 PlayerModeAutoEntry, 10 MouseLookState, 3 ChatPanel
focus). 1183 total green. 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 09:20:17 +02:00
parent da189103b8
commit af74eac0c2
12 changed files with 972 additions and 66 deletions

View file

@ -0,0 +1,155 @@
using AcDream.App.Input;
namespace AcDream.Core.Tests.Input;
/// <summary>
/// Phase K.2 — guard logic for auto-entering player mode at login.
/// The trigger fires exactly once when:
/// <list type="number">
/// <item>It has been <see cref="PlayerModeAutoEntry.Arm"/>'d (login
/// succeeded).</item>
/// <item>The live session is in <c>InWorld</c>.</item>
/// <item>The player entity has been streamed into the local
/// dictionary.</item>
/// <item>The player movement controller is ready to attach.</item>
/// </list>
/// All four predicates are passed as <see cref="Func{bool}"/>; tests
/// flip plain boolean fields and assert against an entry-counter that
/// the entry callback bumps.
/// </summary>
public sealed class AutoEnterPlayerModeTests
{
private sealed class State
{
public bool LiveInWorld;
public bool PlayerEntityPresent;
public bool PlayerControllerReady;
public int EnteredCount;
public PlayerModeAutoEntry Build() =>
new(
isLiveInWorld: () => LiveInWorld,
isPlayerEntityPresent: () => PlayerEntityPresent,
isPlayerControllerReady: () => PlayerControllerReady,
enterPlayerMode: () => EnteredCount++);
}
[Fact]
public void TryEnter_NotArmed_DoesNotFire()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
// Not armed → must NOT fire even though every precondition is true.
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.False(guard.IsArmed);
}
[Fact]
public void TryEnter_Armed_LiveNotInWorld_DoesNotFire()
{
var s = new State { LiveInWorld = false, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
}
[Fact]
public void TryEnter_Armed_PlayerEntityNotPresent_DoesNotFire()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = false, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
}
[Fact]
public void TryEnter_Armed_PlayerControllerNotReady_DoesNotFire()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = false };
var guard = s.Build();
guard.Arm();
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
}
[Fact]
public void TryEnter_AllConditionsSatisfied_FiresExactlyOnce()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
Assert.False(guard.IsArmed);
// Subsequent tick must not re-fire — one-shot semantics.
Assert.False(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
[Fact]
public void TryEnter_FiresOnLaterTickWhenPreconditionsBecomeTrue()
{
var s = new State();
var guard = s.Build();
guard.Arm();
// Tick 1: only LiveInWorld true.
s.LiveInWorld = true;
Assert.False(guard.TryEnter());
// Tick 2: + PlayerEntityPresent.
s.PlayerEntityPresent = true;
Assert.False(guard.TryEnter());
// Tick 3: + PlayerControllerReady → fires.
s.PlayerControllerReady = true;
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
[Fact]
public void Cancel_BeforeFiring_SuppressesAutoEntry()
{
// Manual fly-mode toggle BEFORE the auto-entry fires must
// disarm the trigger; the user's choice wins.
var s = new State();
var guard = s.Build();
guard.Arm();
// User opts out before any precondition is true.
guard.Cancel();
Assert.False(guard.IsArmed);
// Even when every precondition flips true, the guard stays
// silent — the user's manual fly-mode choice wins.
s.LiveInWorld = true;
s.PlayerEntityPresent = true;
s.PlayerControllerReady = true;
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
}
[Fact]
public void Arm_WhileAlreadyArmed_IsIdempotent()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
guard.Arm(); // second Arm() — no-op.
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
}