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:
parent
8b8afeefa3
commit
4eae9b4f5a
25 changed files with 2608 additions and 418 deletions
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal file
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue