User report: "ESC is hardwired to Freefly which it should not be ... the freefly should really be discarded. Should not be in the client." Two separate things were true. Escape ran a priority chain — cancel target mode, else EXIT FLY MODE, else leave player mode, else close a window — so in a session that had reached the free-fly camera, Escape spent itself on that rung instead of doing what the player expected. The rung is gone; a session somehow in fly mode now falls through to the next one. And free-fly was still bound: Ctrl+Shift+F in RetailDefaults (the table production actually loads) and plain F in AcdreamCurrentDefaults (dead since K.1c, removed anyway so it cannot be revived by accident). The comment on the live binding advertised two other ways in — the ImGui View menu and the Debug panel's "Toggle Free-Fly Mode" button — but BOTH went away with AcDream.UI.ImGui at Campaign V, so the shortcut was the last route in. It is now unbound, and a test pins that across both default tables. This makes free-fly unreachable rather than deleted. The implementation still spans 25 files (CameraController, FlyCamera, the dispatcher capture, pointer controller, composition, and a streaming observer source), and ripping that out at the end of a long session is how a regression lands in the camera. Scoped as its own follow-up; unbinding is what fixes the reported behaviour today. The Escape priority test was updated rather than deleted: its middle row now asserts the fall-through, so the removed rung is documented by a passing test instead of by its absence. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
9.4 KiB
C#
255 lines
9.4 KiB
C#
using AcDream.App.Combat;
|
|
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Input;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.App.Tests.Input;
|
|
|
|
public sealed class GameplayInputCommandControllerTests
|
|
{
|
|
[Theory]
|
|
[InlineData(InputAction.ToggleInventoryPanel, "inventory")]
|
|
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
|
|
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
|
|
// Campaign OP slice OP3 (D1): F11 now opens the retail Options panel
|
|
// through IRetainedGameplayWindowCommands, not the OP9-retired
|
|
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op (which was
|
|
// never dispatched from this switch in the first place).
|
|
[InlineData(InputAction.ToggleOptionsPanel, "options")]
|
|
[InlineData(InputAction.CombatToggleCombat, "combat")]
|
|
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
|
|
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
|
|
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
|
|
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
|
|
public void RecognizedCommand_RoutesToTypedOwner(
|
|
InputAction action,
|
|
string expected)
|
|
{
|
|
var harness = new Harness();
|
|
|
|
bool handled = harness.Controller.Handle(action);
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal([expected], harness.Calls);
|
|
}
|
|
|
|
// OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the
|
|
// IDevToolsGameplayCommands seam they used to forward to — both
|
|
// targets (the ImGui-era DebugPanel/ChatPanel) were already gone
|
|
// (Campaign V slice V11), so the seam's own body was an unconditional
|
|
// no-op. The action is still consumed (handled == true, matching the
|
|
// prior no-op's contract) but claims no typed-owner call.
|
|
[Theory]
|
|
[InlineData(InputAction.AcdreamToggleDebugPanel)]
|
|
[InlineData(InputAction.ToggleChatEntry)]
|
|
public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner(
|
|
InputAction action)
|
|
{
|
|
var harness = new Harness();
|
|
|
|
bool handled = harness.Controller.Handle(action);
|
|
|
|
Assert.True(handled);
|
|
Assert.Empty(harness.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void DiagnosticCommand_PrecedesRemainingCommandSwitch()
|
|
{
|
|
var harness = new Harness();
|
|
harness.Diagnostics.HandledAction = InputAction.AcdreamToggleDebugPanel;
|
|
|
|
bool handled = harness.Controller.Handle(
|
|
InputAction.AcdreamToggleDebugPanel);
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal(["diagnostic"], harness.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownCommand_IsNotClaimed()
|
|
{
|
|
var harness = new Harness();
|
|
|
|
bool handled = harness.Controller.Handle(InputAction.MovementForward);
|
|
|
|
Assert.False(handled);
|
|
Assert.Empty(harness.Calls);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Escape's priority chain: cancel a target mode, else leave player mode,
|
|
/// else close a window.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly
|
|
/// "should not be in the client"). The middle row below is the proof — a
|
|
/// session that is somehow in fly mode now falls through to the next rung
|
|
/// rather than silently exiting a camera the player has no way to enter.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(true, true, true, "cancel-target")]
|
|
[InlineData(false, true, true, "exit-player")]
|
|
[InlineData(false, false, true, "exit-player")]
|
|
[InlineData(false, false, false, "close")]
|
|
public void Escape_PreservesTargetPlayerWindowPriority(
|
|
bool targetMode,
|
|
bool flyMode,
|
|
bool playerMode,
|
|
string expected)
|
|
{
|
|
var harness = new Harness
|
|
{
|
|
TargetMode = { IsActive = targetMode },
|
|
Camera = { IsFly = flyMode },
|
|
Player = { IsPlayer = playerMode },
|
|
};
|
|
|
|
bool handled = harness.Controller.Handle(InputAction.EscapeKey);
|
|
|
|
Assert.True(handled);
|
|
Assert.Equal([expected], harness.Calls);
|
|
}
|
|
|
|
private sealed class Harness
|
|
{
|
|
public Harness()
|
|
{
|
|
Retained = new FakeRetained(Calls);
|
|
Diagnostics = new FakeDiagnostics(Calls);
|
|
Player = new FakePlayerMode(Calls);
|
|
TargetMode = new FakeTargetMode(Calls);
|
|
Camera = new FakeCamera(Calls);
|
|
Combat = new FakeCombat(Calls);
|
|
Runtime = new FakeRuntimeView();
|
|
Window = new FakeWindow(Calls);
|
|
Controller = new GameplayInputCommandController(
|
|
Retained,
|
|
Diagnostics,
|
|
Player,
|
|
TargetMode,
|
|
Camera,
|
|
Runtime,
|
|
Combat,
|
|
Window);
|
|
}
|
|
|
|
public List<string> Calls { get; } = [];
|
|
public FakeRetained Retained { get; }
|
|
public FakeDiagnostics Diagnostics { get; }
|
|
public FakePlayerMode Player { get; }
|
|
public FakeTargetMode TargetMode { get; }
|
|
public FakeCamera Camera { get; }
|
|
public FakeCombat Combat { get; }
|
|
public FakeRuntimeView Runtime { get; }
|
|
public FakeWindow Window { get; }
|
|
public GameplayInputCommandController Controller { get; }
|
|
}
|
|
|
|
private sealed class FakeRetained(List<string> calls)
|
|
: IRetainedGameplayWindowCommands
|
|
{
|
|
public void ToggleInventory() => calls.Add("inventory");
|
|
|
|
public void ToggleFloatingChatWindow(int windowId) =>
|
|
calls.Add($"chat-window-{windowId}");
|
|
|
|
public void ToggleOptionsPanel() => calls.Add("options");
|
|
}
|
|
|
|
private sealed class FakeDiagnostics(List<string> calls)
|
|
: IRuntimeDiagnosticCommands
|
|
{
|
|
public InputAction? HandledAction { get; set; }
|
|
|
|
public bool Handle(InputAction action)
|
|
{
|
|
if (action != HandledAction)
|
|
return false;
|
|
calls.Add("diagnostic");
|
|
return true;
|
|
}
|
|
|
|
public void CycleTimeOfDay() => calls.Add("time");
|
|
public void CycleWeather() => calls.Add("weather");
|
|
public void ToggleCollisionWireframes() => calls.Add("collision");
|
|
}
|
|
|
|
private sealed class FakePlayerMode(List<string> calls)
|
|
: IPlayerModeGameplayCommands
|
|
{
|
|
public bool IsPlayer { get; set; }
|
|
public bool IsPlayerMode => IsPlayer;
|
|
public void ToggleFlyOrChase() => calls.Add("fly-or-chase");
|
|
public void TogglePlayerMode() => calls.Add("player-mode");
|
|
public void ExitPlayerMode() => calls.Add("exit-player");
|
|
}
|
|
|
|
private sealed class FakeTargetMode(List<string> calls)
|
|
: IItemTargetModeCommands
|
|
{
|
|
public bool IsActive { get; set; }
|
|
public bool IsAnyTargetModeActive => IsActive;
|
|
public void CancelTargetMode() => calls.Add("cancel-target");
|
|
}
|
|
|
|
private sealed class FakeCamera(List<string> calls)
|
|
: IGameplayCameraModeCommands
|
|
{
|
|
public bool IsFly { get; set; }
|
|
public bool IsFlyMode => IsFly;
|
|
public void ExitFlyMode() => calls.Add("exit-fly");
|
|
}
|
|
|
|
private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands
|
|
{
|
|
public RuntimeCommandResult Execute(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
RuntimeCombatCommand command)
|
|
{
|
|
Assert.Equal(RuntimeCombatCommand.ToggleMode, command);
|
|
calls.Add("combat");
|
|
return new RuntimeCommandResult(
|
|
RuntimeCommandStatus.Accepted,
|
|
expectedGeneration);
|
|
}
|
|
|
|
public RuntimeCommandResult ExecuteAttack(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeCombatAttackInput command) =>
|
|
new(
|
|
RuntimeCommandStatus.Unsupported,
|
|
expectedGeneration);
|
|
}
|
|
|
|
private sealed class FakeRuntimeView : IGameRuntimeView
|
|
{
|
|
public RuntimeGenerationToken Generation => new(7);
|
|
public RuntimeLifecycleSnapshot Lifecycle => throw new NotSupportedException();
|
|
public IGameRuntimeClock Clock => throw new NotSupportedException();
|
|
public IRuntimeEntityView Entities => throw new NotSupportedException();
|
|
public IRuntimeInventoryView Inventory => throw new NotSupportedException();
|
|
public IRuntimeInventoryStateView InventoryState =>
|
|
throw new NotSupportedException();
|
|
public IRuntimeCharacterView Character =>
|
|
throw new NotSupportedException();
|
|
public IRuntimeSocialView Social => throw new NotSupportedException();
|
|
public IRuntimeChatView Chat => throw new NotSupportedException();
|
|
public IRuntimeFellowshipView Fellowship => throw new NotSupportedException();
|
|
public IRuntimeAllegianceView Allegiance => throw new NotSupportedException();
|
|
public IRuntimeActionView Actions => throw new NotSupportedException();
|
|
public IRuntimeMovementView Movement => throw new NotSupportedException();
|
|
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
|
|
throw new NotSupportedException();
|
|
public IRuntimePortalView Portal => throw new NotSupportedException();
|
|
public RuntimeStateCheckpoint CaptureCheckpoint() =>
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
|
|
{
|
|
public void Close() => calls.Add("close");
|
|
}
|
|
}
|