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,214 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.Combat;
internal interface ILiveCombatModeAuthority
{
bool IsInWorld { get; }
void SendChangeCombatMode(CombatMode mode);
}
internal sealed class LiveSessionCombatModeAuthority(LiveSessionHost session)
: ILiveCombatModeAuthority
{
private readonly LiveSessionHost _session = session
?? throw new ArgumentNullException(nameof(session));
public bool IsInWorld =>
_session.IsInWorld && _session.CurrentSession is not null;
public void SendChangeCombatMode(CombatMode mode)
{
if (!IsInWorld)
{
throw new InvalidOperationException(
"A combat-mode request requires an active in-world session.");
}
_session.CurrentSession!.SendChangeCombatMode(mode);
}
}
internal interface ICombatEquipmentSource
{
IReadOnlyList<ClientObject> GetOrderedEquipment();
}
internal sealed class LocalPlayerCombatEquipmentSource(
ClientObjectTable objects,
ILocalPlayerIdentitySource identity) : ICombatEquipmentSource
{
private readonly ClientObjectTable _objects = objects
?? throw new ArgumentNullException(nameof(objects));
private readonly ILocalPlayerIdentitySource _identity = identity
?? throw new ArgumentNullException(nameof(identity));
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_objects.GetEquippedBy(_identity.ServerGuid);
}
internal interface IExplicitCombatModeIntentSink
{
void NotifyExplicitCombatModeRequest();
}
internal sealed class ItemInteractionCombatModeIntentSink(
ItemInteractionController items) : IExplicitCombatModeIntentSink
{
private readonly ItemInteractionController _items = items
?? throw new ArgumentNullException(nameof(items));
public void NotifyExplicitCombatModeRequest() =>
_items.NotifyExplicitCombatModeRequest();
}
internal interface ILiveCombatModeCommand
{
void Toggle();
}
/// <summary>
/// Focused deferred edge shared by the early-created retained toolbar and the
/// later-created gameplay router. It owns no combat state and forwards to one
/// window-lifetime command owner after session composition is complete.
/// </summary>
internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
{
private readonly object _gate = new();
private ILiveCombatModeCommand? _target;
private bool _deactivated;
public void Bind(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Live combat-mode commands are already bound.");
}
_target = target;
}
}
public void Unbind(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public void Toggle()
{
lock (_gate)
{
if (!_deactivated)
_target?.Toggle();
}
}
}
/// <summary>
/// Owns the input-facing combat-mode command. The choice itself remains the
/// retail port in <see cref="CombatInputPlanner"/>:
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
/// authority; the local state update preserves the accepted immediate toolbar
/// response while the server confirmation travels through the normal route.
/// </summary>
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
{
private readonly ILiveCombatModeAuthority _authority;
private readonly ICombatEquipmentSource _equipment;
private readonly CombatState _combat;
private readonly IExplicitCombatModeIntentSink _itemIntent;
private readonly Action<string> _log;
private readonly Action<string>? _toast;
private readonly Action<string> _systemMessage;
public LiveCombatModeCommandController(
ILiveCombatModeAuthority authority,
ICombatEquipmentSource equipment,
CombatState combat,
IExplicitCombatModeIntentSink itemIntent,
Action<string>? log = null,
Action<string>? toast = null,
Action<string>? systemMessage = null)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
_log = log ?? (_ => { });
_toast = toast;
_systemMessage = systemMessage ?? (_ => { });
}
public void Toggle()
{
if (!_authority.IsInWorld)
return;
// Every explicit user combat request supersedes an in-flight auto-wield
// settlement, including a request retail rejects before SetCombatMode.
_itemIntent.NotifyExplicitCombatModeRequest();
CombatMode currentMode = _combat.CurrentMode;
CombatMode nextMode;
if (currentMode != CombatMode.NonCombat)
{
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and
// never queries default equipment on this branch.
nextMode = CombatMode.NonCombat;
}
else
{
DefaultCombatModeDecision defaultDecision =
CombatInputPlanner.GetDefaultCombatModeDecision(
_equipment.GetOrderedEquipment());
nextMode = CombatInputPlanner.ToggleMode(
currentMode,
defaultDecision.Mode);
// GetDefaultCombatMode prints this notice before SetCombatMode.
// SetCombatMode then sees NonCombat == NonCombat and does no work.
if (defaultDecision.IncompatibleHeldItem is { } held)
{
string notice =
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
_log($"combat: {notice}");
_systemMessage(notice);
return;
}
}
// Preserve the accepted command order: explicit intent was published
// above, then send the request and publish local presentation.
_authority.SendChangeCombatMode(nextMode);
_combat.SetCombatMode(nextMode);
string message = $"Combat mode {nextMode}";
_log($"combat: {message}");
_toast?.Invoke(message);
}
}