refactor(input): own gameplay action routing

Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 12:43:05 +02:00
parent 8b8afeefa3
commit 4eae9b4f5a
25 changed files with 2608 additions and 418 deletions

View file

@ -0,0 +1,328 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
public sealed class GameplayInputActionRouterTests
{
[Theory]
[InlineData("pointer", "pointer")]
[InlineData("combat", "pointer,combat")]
[InlineData("retained", "pointer,combat,retained")]
[InlineData("selection", "pointer,combat,retained,selection")]
[InlineData("movement", "pointer,combat,retained,selection,movement")]
[InlineData("command", "pointer,combat,retained,selection,movement,command")]
public void Press_PreservesFrozenPriorityAndStopsAtConsumer(
string consumeAt,
string expectedCsv)
{
var harness = new Harness(consumeAt);
harness.Router.Attach();
harness.Actions.Raise(
InputAction.AcdreamToggleDebugPanel,
ActivationType.Press);
Assert.Equal(expectedCsv.Split(','), harness.Targets.Calls);
}
[Fact]
public void Scroll_IsBetweenPointerAndCombat_AndOnlyPressMovesIt()
{
var harness = new Harness();
harness.Router.Attach();
harness.Actions.Raise(InputAction.ScrollUp, ActivationType.Release);
Assert.Equal(["pointer"], harness.Targets.Calls);
harness.Targets.Calls.Clear();
harness.Actions.Raise(InputAction.ScrollDown, ActivationType.Press);
Assert.Equal(["pointer", "scroll:ScrollDown"], harness.Targets.Calls);
}
[Fact]
public void Release_ReachesCombatThenStopsAtPressGate()
{
var harness = new Harness();
harness.Router.Attach();
harness.Actions.Raise(
InputAction.CombatHighAttack,
ActivationType.Release);
Assert.Equal(["pointer", "combat"], harness.Targets.Calls);
}
[Fact]
public void DoubleClick_PassesGateToRetainedSelectionMovementAndCommands()
{
var harness = new Harness();
harness.Router.Attach();
harness.Actions.Raise(
InputAction.SelectDblLeft,
ActivationType.DoubleClick);
Assert.Equal(
["pointer", "combat", "retained", "selection", "movement", "command"],
harness.Targets.Calls);
}
[Fact]
public void Attach_SeedsAndTracksCombatScopes()
{
var harness = new Harness();
harness.Combat.CurrentModeValue = CombatMode.Missile;
harness.Router.Attach();
harness.Combat.Raise(CombatMode.Magic);
harness.Combat.Raise(CombatMode.NonCombat);
Assert.Equal(
[InputScope.MissileCombat, InputScope.MagicCombat, null],
harness.Actions.Scopes);
}
[Theory]
[InlineData(0, "remove-actions")]
[InlineData(1, "remove-combat,remove-actions")]
public void AttachFailureAfterSideEffect_RollsBackExactPrefixInReverse(
int failingEdge,
string expectedRemovalCsv)
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls)
{
ThrowAfterAdd = failingEdge == 0,
};
var combat = new FakeCombatSurface(calls)
{
ThrowAfterAdd = failingEdge == 1,
};
var router = Create(actions, combat, new FakeTargets());
Assert.Throws<InvalidOperationException>(router.Attach);
Assert.Equal(expectedRemovalCsv.Split(','), calls.Where(
call => call.StartsWith("remove", StringComparison.Ordinal)));
Assert.True(router.IsDisposalComplete);
}
[Fact]
public void ReentrantDeliveryDuringAdd_CannotEnterPartialRouter()
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls)
{
RaiseDuringAdd = true,
};
var targets = new FakeTargets();
var router = Create(actions, new FakeCombatSurface(calls), targets);
router.Attach();
Assert.Empty(targets.Calls);
actions.Raise(InputAction.AcdreamToggleDebugPanel, ActivationType.Press);
Assert.NotEmpty(targets.Calls);
}
[Fact]
public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose()
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls)
{
ThrowAfterAdd = true,
RemoveFailures = int.MaxValue,
};
var router = Create(
actions,
new FakeCombatSurface(calls),
new FakeTargets());
Assert.Throws<AggregateException>(router.Attach);
Assert.False(router.IsDisposalComplete);
actions.RemoveFailures = 0;
router.Dispose();
Assert.True(router.IsDisposalComplete);
Assert.Null(actions.Callback);
}
[Fact]
public void Dispose_RetriesOnlyFailedRemovalAndNeverReplaysSuccess()
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls);
var combat = new FakeCombatSurface(calls)
{
RemoveFailures = int.MaxValue,
};
var router = Create(actions, combat, new FakeTargets());
router.Attach();
Assert.Throws<AggregateException>(router.Dispose);
Assert.False(router.IsDisposalComplete);
combat.RemoveFailures = 0;
router.Dispose();
Assert.True(router.IsDisposalComplete);
Assert.Equal(4, calls.Count(call => call == "remove-combat"));
Assert.Equal(1, calls.Count(call => call == "remove-actions"));
}
[Fact]
public void Deactivate_MakesCopiedCallbacksSilentBeforePhysicalDetach()
{
var harness = new Harness();
harness.Router.Attach();
Action<InputAction, ActivationType> copiedAction =
harness.Actions.Callback!;
Action<CombatMode> copiedCombat = harness.Combat.Callback!;
harness.Router.Deactivate();
copiedAction(InputAction.AcdreamToggleDebugPanel, ActivationType.Press);
copiedCombat(CombatMode.Melee);
Assert.Empty(harness.Targets.Calls);
Assert.Equal([null], harness.Actions.Scopes);
}
[Fact]
public void DisposeBeforeAttach_IsTerminalAndCannotResurrectCallbacks()
{
var harness = new Harness();
harness.Router.Dispose();
Assert.True(harness.Router.IsDisposalComplete);
Assert.Throws<ObjectDisposedException>(harness.Router.Attach);
Assert.Null(harness.Actions.Callback);
Assert.Null(harness.Combat.Callback);
}
private static GameplayInputActionRouter Create(
FakeActionSurface actions,
FakeCombatSurface combat,
FakeTargets targets) =>
new(actions, combat, targets, new HostQuiescenceGate());
private sealed class Harness
{
public Harness(string? consumeAt = null)
{
var calls = new List<string>();
Actions = new FakeActionSurface(calls);
Combat = new FakeCombatSurface(calls);
Targets = new FakeTargets(consumeAt);
Router = Create(Actions, Combat, Targets);
}
public FakeActionSurface Actions { get; }
public FakeCombatSurface Combat { get; }
public FakeTargets Targets { get; }
public GameplayInputActionRouter Router { get; }
}
private sealed class FakeActionSurface(List<string> calls)
: IGameplayInputActionSurface
{
public Action<InputAction, ActivationType>? Callback { get; private set; }
public bool ThrowAfterAdd { get; set; }
public bool RaiseDuringAdd { get; set; }
public int RemoveFailures { get; set; }
public List<InputScope?> Scopes { get; } = [];
public void AddFired(Action<InputAction, ActivationType> callback)
{
calls.Add("add-actions");
Callback = callback;
if (RaiseDuringAdd)
{
callback(
InputAction.AcdreamToggleDebugPanel,
ActivationType.Press);
}
if (ThrowAfterAdd)
throw new InvalidOperationException("actions add");
}
public void RemoveFired(Action<InputAction, ActivationType> callback)
{
calls.Add("remove-actions");
if (RemoveFailures-- > 0)
throw new InvalidOperationException("actions remove");
if (ReferenceEquals(Callback, callback))
Callback = null;
}
public void SetCombatScope(InputScope? scope) => Scopes.Add(scope);
public void Raise(InputAction action, ActivationType activation) =>
Callback?.Invoke(action, activation);
}
private sealed class FakeCombatSurface(List<string> calls)
: ICombatModeEventSurface
{
public CombatMode CurrentModeValue { get; set; } = CombatMode.NonCombat;
public CombatMode CurrentMode => CurrentModeValue;
public Action<CombatMode>? Callback { get; private set; }
public bool ThrowAfterAdd { get; set; }
public int RemoveFailures { get; set; }
public void AddChanged(Action<CombatMode> callback)
{
calls.Add("add-combat");
Callback = callback;
if (ThrowAfterAdd)
throw new InvalidOperationException("combat add");
}
public void RemoveChanged(Action<CombatMode> callback)
{
calls.Add("remove-combat");
if (RemoveFailures-- > 0)
throw new InvalidOperationException("combat remove");
if (ReferenceEquals(Callback, callback))
Callback = null;
}
public void Raise(CombatMode mode) => Callback?.Invoke(mode);
}
private sealed class FakeTargets(string? consumeAt = null)
: IGameplayInputPriorityTargets
{
public List<string> Calls { get; } = [];
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
Record("pointer");
public void HandleScroll(InputAction action) => Calls.Add($"scroll:{action}");
public bool HandleCombatAction(InputAction action, ActivationType activation) =>
Record("combat");
public bool HandleRetainedUiAction(InputAction action) =>
Record("retained");
public bool HandleSelectionAction(InputAction action) =>
Record("selection");
public bool HandlePressedMovementAction(InputAction action) =>
Record("movement");
public void HandleCommand(InputAction action) => Calls.Add("command");
private bool Record(string name)
{
Calls.Add(name);
return consumeAt == name;
}
}
}

View file

@ -0,0 +1,180 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
public sealed class GameplayInputCommandControllerTests
{
[Theory]
[InlineData(InputAction.ToggleInventoryPanel, "inventory")]
[InlineData(InputAction.AcdreamToggleDebugPanel, "debug")]
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
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);
}
[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);
}
[Theory]
[InlineData(true, true, true, "cancel-target")]
[InlineData(false, true, true, "exit-fly")]
[InlineData(false, false, true, "exit-player")]
[InlineData(false, false, false, "close")]
public void Escape_PreservesTargetFlyPlayerWindowPriority(
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);
DevTools = new FakeDevTools(Calls);
Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls);
Camera = new FakeCamera(Calls);
Combat = new FakeCombat(Calls);
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController(
Retained,
DevTools,
Diagnostics,
Player,
TargetMode,
Camera,
Combat,
Window);
}
public List<string> Calls { get; } = [];
public FakeRetained Retained { get; }
public FakeDevTools DevTools { get; }
public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; }
public FakeCamera Camera { get; }
public FakeCombat Combat { get; }
public FakeWindow Window { get; }
public GameplayInputCommandController Controller { get; }
}
private sealed class FakeRetained(List<string> calls)
: IRetainedGameplayWindowCommands
{
public void ToggleInventory() => calls.Add("inventory");
}
private sealed class FakeDevTools(List<string> calls)
: IDevToolsGameplayCommands
{
public void ToggleDebugPanel() => calls.Add("debug");
public void FocusChatInput() => calls.Add("chat");
public void ToggleSettingsPanel() => calls.Add("settings");
}
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) : ILiveCombatModeCommand
{
public void Toggle() => calls.Add("combat");
}
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
{
public void Close() => calls.Add("close");
}
}

View file

@ -0,0 +1,158 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.UI;
namespace AcDream.App.Tests.Input;
public sealed class RetainedUiGameplayBindingTests
{
[Fact]
public void AttachedBinding_RoutesOnlyItemPayloads()
{
var surface = new Surface();
var calls = new List<(uint Id, int X, int Y)>();
var binding = Create(surface, calls);
binding.Attach();
surface.Raise("not an item", 1, 2);
surface.Raise(Item(42), 7, 9);
Assert.Equal([(42u, 7, 9)], calls);
}
[Fact]
public void AddAfterSideEffectFailure_RollsBackExactCallback()
{
var surface = new Surface { ThrowAfterAdd = true };
var binding = Create(surface, []);
Assert.Throws<InvalidOperationException>(binding.Attach);
Assert.True(binding.IsDisposalComplete);
Assert.Null(surface.Callback);
Assert.Equal(1, surface.RemoveCalls);
}
[Fact]
public void ReentrantDeliveryDuringAdd_CannotEnterPartialBinding()
{
var surface = new Surface { RaiseDuringAdd = true };
var calls = new List<(uint Id, int X, int Y)>();
var binding = Create(surface, calls);
binding.Attach();
Assert.Empty(calls);
surface.Raise(Item(2), 3, 4);
Assert.Equal([(2u, 3, 4)], calls);
}
[Fact]
public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose()
{
var surface = new Surface
{
ThrowAfterAdd = true,
RemoveFailures = int.MaxValue,
};
var binding = Create(surface, []);
Assert.Throws<AggregateException>(binding.Attach);
Assert.False(binding.IsDisposalComplete);
surface.RemoveFailures = 0;
binding.Dispose();
Assert.True(binding.IsDisposalComplete);
Assert.Null(surface.Callback);
}
[Fact]
public void Deactivate_MakesCopiedCallbackSilentBeforePhysicalDetach()
{
var surface = new Surface();
var calls = new List<(uint Id, int X, int Y)>();
var binding = Create(surface, calls);
binding.Attach();
Action<object, int, int> copied = surface.Callback!;
binding.Deactivate();
copied(Item(8), 1, 1);
Assert.Empty(calls);
Assert.False(binding.IsDisposalComplete);
}
[Fact]
public void Dispose_RetriesFailedRemovalWithoutReattaching()
{
var surface = new Surface { RemoveFailures = int.MaxValue };
var binding = Create(surface, []);
binding.Attach();
Assert.Throws<AggregateException>(binding.Dispose);
Assert.False(binding.IsDisposalComplete);
surface.RemoveFailures = 0;
binding.Dispose();
Assert.True(binding.IsDisposalComplete);
Assert.Equal(1, surface.AddCalls);
Assert.Equal(3, surface.RemoveCalls);
}
[Fact]
public void DisposeBeforeAttach_IsTerminal()
{
var surface = new Surface();
var binding = Create(surface, []);
binding.Dispose();
Assert.True(binding.IsDisposalComplete);
Assert.Throws<ObjectDisposedException>(binding.Attach);
Assert.Null(surface.Callback);
}
private static RetainedUiGameplayBinding Create(
Surface surface,
List<(uint Id, int X, int Y)> calls) =>
new(
surface,
(item, x, y) => calls.Add((item.ObjId, x, y)),
new HostQuiescenceGate());
private static ItemDragPayload Item(uint id) =>
new(id, ItemDragSource.Inventory, 0, SourceCell: null!);
private sealed class Surface : IRetainedUiDragReleaseSurface
{
public Action<object, int, int>? Callback { get; private set; }
public bool ThrowAfterAdd { get; set; }
public bool RaiseDuringAdd { get; set; }
public int RemoveFailures { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public void AddReleasedOutside(Action<object, int, int> callback)
{
AddCalls++;
Callback = callback;
if (RaiseDuringAdd)
callback(Item(1), 1, 1);
if (ThrowAfterAdd)
throw new InvalidOperationException("add");
}
public void RemoveReleasedOutside(Action<object, int, int> callback)
{
RemoveCalls++;
if (RemoveFailures-- > 0)
throw new InvalidOperationException("remove");
if (ReferenceEquals(Callback, callback))
Callback = null;
}
public void Raise(object payload, int x, int y) =>
Callback?.Invoke(payload, x, y);
}
}