refactor(runtime): own combat and magic intent

Move attack build/repeat state, combat-mode policy, authoritative auto-target transitions, and spell-cast intent beneath RuntimeActionState. Keep App as the input, world-query, DAT-policy, transport, and presentation adapter while preserving retail request and busy ordering. Add direct/graphical parity, reset, failure, and instance-isolation coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 11:56:40 +02:00
parent 81b31857c6
commit 20df9d155d
49 changed files with 1949 additions and 634 deletions

View file

@ -3,6 +3,20 @@ using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime;
public readonly record struct RuntimeCombatAttackSnapshot(
long Revision,
AttackHeight RequestedHeight,
float DesiredPower,
float PowerBarLevel,
bool BuildInProgress,
bool RequestInProgress,
float RequestedPower);
public readonly record struct RuntimeSpellCastSnapshot(
long Revision,
uint LastRequestedSpellId,
uint LastRequestedTargetId);
public readonly record struct RuntimeActionSnapshot(
long SelectionRevision,
uint SelectedObjectId,
@ -14,7 +28,9 @@ public readonly record struct RuntimeActionSnapshot(
long InteractionRevision,
InteractionModeKind InteractionMode,
uint InteractionSourceObjectId,
RuntimeInteractionTransactionSnapshot InteractionTransactions);
RuntimeInteractionTransactionSnapshot InteractionTransactions,
RuntimeCombatAttackSnapshot CombatAttack = default,
RuntimeSpellCastSnapshot Magic = default);
public interface IRuntimeActionView
{

View file

@ -112,6 +112,19 @@ public interface IRuntimeCombatCommands
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command);
RuntimeCommandResult ExecuteAttack(
RuntimeGenerationToken expectedGeneration,
in Gameplay.RuntimeCombatAttackInput command);
}
public readonly record struct RuntimeMagicCommand(uint SpellId);
public interface IRuntimeMagicCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeMagicCommand command);
}
public interface IRuntimeMovementCommands
@ -251,6 +264,8 @@ public interface IGameRuntimeCommands
IRuntimeCombatCommands Combat { get; }
IRuntimeMagicCommands Magic { get; }
IRuntimeMovementCommands Movement { get; }
IRuntimeChatCommands Chat { get; }

View file

@ -7,16 +7,17 @@ public readonly record struct RuntimeEventStamp(
public enum RuntimeCommandDomain
{
Session,
Selection,
Combat,
Movement,
Chat,
Portal,
InventoryState,
Spellbook,
Character,
Social,
Session = 0,
Selection = 1,
Combat = 2,
Movement = 3,
Chat = 4,
Portal = 5,
InventoryState = 6,
Spellbook = 7,
Character = 8,
Social = 9,
Magic = 10,
}
public readonly record struct RuntimeLifecycleDelta(
@ -176,7 +177,17 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" +
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken};" +
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken}:" +
$"{checkpoint.Actions.CombatAttack.Revision}:" +
$"{(int)checkpoint.Actions.CombatAttack.RequestedHeight}:" +
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.DesiredPower):X8}:" +
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.PowerBarLevel):X8}:" +
$"{checkpoint.Actions.CombatAttack.BuildInProgress}:" +
$"{checkpoint.Actions.CombatAttack.RequestInProgress}:" +
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.RequestedPower):X8}:" +
$"{checkpoint.Actions.Magic.Revision}:" +
$"{checkpoint.Actions.Magic.LastRequestedSpellId:X8}:" +
$"{checkpoint.Actions.Magic.LastRequestedTargetId:X8};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

@ -1,6 +1,7 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay;
@ -8,6 +9,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
bool IsDisposed,
bool InternalSubscriptionsAttached,
bool InteractionTransactionsDisposed,
bool CombatAttackDisposed,
bool CombatTargetDisposed,
bool SpellCastReset,
uint SelectedObjectId,
uint PreviousObjectId,
uint PreviousValidObjectId,
@ -23,6 +27,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
IsDisposed
&& !InternalSubscriptionsAttached
&& InteractionTransactionsDisposed
&& CombatAttackDisposed
&& CombatTargetDisposed
&& SpellCastReset
&& SelectedObjectId == 0u
&& PreviousObjectId == 0u
&& PreviousValidObjectId == 0u
@ -33,9 +40,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
}
/// <summary>
/// Canonical presentation-independent owner for selection, combat
/// notifications/mode, and temporary interaction target mode. Graphical and
/// no-window hosts borrow these exact instances.
/// Canonical presentation-independent owner for selection, interaction
/// transactions, combat mode/attack/target intent, and spell-cast intent.
/// Graphical and no-window hosts borrow these exact instances.
/// </summary>
public sealed class RuntimeActionState : IDisposable
{
@ -44,21 +51,52 @@ public sealed class RuntimeActionState : IDisposable
private long _selectionRevision;
private long _combatRevision;
private long _interactionRevision;
private long _combatIntentRevision;
private long _magicIntentRevision;
public RuntimeActionState(InventoryTransactionState inventoryTransactions)
public RuntimeActionState(
InventoryTransactionState inventoryTransactions,
Spellbook spellbook,
IRuntimeCombatAttackOperations combatAttackOperations,
IRuntimeCombatTargetOperations combatTargetOperations,
IRuntimeCombatModeOperations combatModeOperations,
IRuntimeSpellCastOperations spellCastOperations,
Func<double>? now = null)
{
ArgumentNullException.ThrowIfNull(inventoryTransactions);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(combatAttackOperations);
ArgumentNullException.ThrowIfNull(combatTargetOperations);
ArgumentNullException.ThrowIfNull(combatModeOperations);
ArgumentNullException.ThrowIfNull(spellCastOperations);
Selection = new SelectionState();
Combat = new CombatState();
Interaction = new InteractionState();
Transactions = new RuntimeInteractionTransactionState(
inventoryTransactions);
CombatAttack = new RuntimeCombatAttackState(
Combat,
combatAttackOperations,
now);
CombatTarget = new RuntimeCombatTargetState(
Combat,
Selection,
combatTargetOperations);
CombatMode = new RuntimeCombatModeState(
Combat,
combatModeOperations);
SpellCast = new RuntimeSpellCastState(
spellbook,
Selection,
spellCastOperations);
View = new ActionView(this);
Selection.Changed += OnSelectionChanged;
Combat.CombatModeChanged += OnCombatModeChanged;
Combat.HealthChanged += OnHealthChanged;
Interaction.Changed += OnInteractionChanged;
CombatAttack.StateChanged += OnCombatAttackChanged;
SpellCast.StateChanged += OnSpellCastChanged;
_internalSubscriptionsAttached = true;
}
@ -66,6 +104,10 @@ public sealed class RuntimeActionState : IDisposable
public CombatState Combat { get; }
public InteractionState Interaction { get; }
public RuntimeInteractionTransactionState Transactions { get; }
public RuntimeCombatAttackState CombatAttack { get; }
public RuntimeCombatTargetState CombatTarget { get; }
public RuntimeCombatModeState CombatMode { get; }
public RuntimeSpellCastState SpellCast { get; }
public IRuntimeActionView View { get; }
public bool IsDisposed => _disposed;
@ -73,6 +115,10 @@ public sealed class RuntimeActionState : IDisposable
_disposed,
_internalSubscriptionsAttached,
Transactions.IsDisposed,
CombatAttack.IsDisposed,
CombatTarget.IsDisposed,
SpellCast.LastRequestedSpellId is null
&& SpellCast.LastRequestedTargetId is null,
Selection.SelectedObjectId ?? 0u,
Selection.PreviousObjectId ?? 0u,
Selection.PreviousValidObjectId ?? 0u,
@ -95,6 +141,8 @@ public sealed class RuntimeActionState : IDisposable
List<Exception>? failures = null;
Try(Transactions.ResetSession, ref failures);
Try(Interaction.ResetSession, ref failures);
Try(SpellCast.Reset, ref failures);
Try(CombatAttack.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures);
if (failures is not null)
@ -115,6 +163,8 @@ public sealed class RuntimeActionState : IDisposable
{
Try(Transactions.Dispose, ref failures);
Try(Interaction.ResetSession, ref failures);
Try(SpellCast.Reset, ref failures);
Try(CombatAttack.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures);
}
@ -124,6 +174,10 @@ public sealed class RuntimeActionState : IDisposable
Combat.CombatModeChanged -= OnCombatModeChanged;
Combat.HealthChanged -= OnHealthChanged;
Interaction.Changed -= OnInteractionChanged;
CombatAttack.StateChanged -= OnCombatAttackChanged;
SpellCast.StateChanged -= OnSpellCastChanged;
Try(CombatAttack.Dispose, ref failures);
Try(CombatTarget.Dispose, ref failures);
_internalSubscriptionsAttached = false;
_disposed = true;
}
@ -148,6 +202,12 @@ public sealed class RuntimeActionState : IDisposable
private void OnInteractionChanged(InteractionModeTransition _) =>
Interlocked.Increment(ref _interactionRevision);
private void OnCombatAttackChanged() =>
Interlocked.Increment(ref _combatIntentRevision);
private void OnSpellCastChanged() =>
Interlocked.Increment(ref _magicIntentRevision);
private static void Try(Action action, ref List<Exception>? failures)
{
try
@ -174,7 +234,19 @@ public sealed class RuntimeActionState : IDisposable
Interlocked.Read(ref owner._interactionRevision),
owner.Interaction.Current.Kind,
owner.Interaction.Current.SourceObjectId,
owner.Transactions.CaptureOwnership());
owner.Transactions.CaptureOwnership(),
new RuntimeCombatAttackSnapshot(
Interlocked.Read(ref owner._combatIntentRevision),
owner.CombatAttack.RequestedHeight,
owner.CombatAttack.DesiredPower,
owner.CombatAttack.PowerBarLevel,
owner.CombatAttack.BuildInProgress,
owner.CombatAttack.AttackRequestInProgress,
owner.CombatAttack.RequestedAttackPower),
new RuntimeSpellCastSnapshot(
Interlocked.Read(ref owner._magicIntentRevision),
owner.SpellCast.LastRequestedSpellId ?? 0u,
owner.SpellCast.LastRequestedTargetId ?? 0u));
public bool TryGetHealth(uint objectId, out float healthPercent)
{

View file

@ -0,0 +1,497 @@
using System.Diagnostics;
using AcDream.Core.Combat;
namespace AcDream.Runtime.Gameplay;
public enum RuntimeInputActivation
{
Press,
Release,
}
public enum RuntimeCombatAttackCommand
{
LowAttack,
MediumAttack,
HighAttack,
DecreasePower,
IncreasePower,
AbortForMovement,
}
public readonly record struct RuntimeCombatAttackInput(
RuntimeCombatAttackCommand Command,
RuntimeInputActivation Activation);
public interface IRuntimeCombatAttackOperations
{
bool CanStartAttack();
void PrepareAttackRequest();
bool SendAttack(AttackHeight height, float power);
void SendCancelAttack();
bool IsDualWield { get; }
bool PlayerReadyForAttack { get; }
bool AutoRepeatAttack { get; }
}
internal sealed class DelegateRuntimeCombatAttackOperations
: IRuntimeCombatAttackOperations
{
private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
public DelegateRuntimeCombatAttackOperations(
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
Action? prepareAttackRequest,
Action? sendCancelAttack,
Func<bool>? isDualWield,
Func<bool>? playerReadyForAttack,
Func<bool>? autoRepeatAttack)
{
_canStartAttack = canStartAttack
?? throw new ArgumentNullException(nameof(canStartAttack));
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
_sendCancelAttack = sendCancelAttack ?? (() => { });
_isDualWield = isDualWield ?? (() => false);
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
}
public bool CanStartAttack() => _canStartAttack();
public void PrepareAttackRequest() => _prepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) =>
_sendAttack(height, power);
public void SendCancelAttack() => _sendCancelAttack();
public bool IsDualWield => _isDualWield();
public bool PlayerReadyForAttack => _playerReadyForAttack();
public bool AutoRepeatAttack => _autoRepeatAttack();
}
/// <summary>
/// Owns retail's basic-combat attack request and power-bar state machine.
/// Graphical controls and no-window hosts both enter through typed Runtime
/// commands; the wire send remains a host callback.
/// </summary>
/// <remarks>
/// Retail references: <c>ClientCombatSystem::Begin</c> (0x0056A460),
/// <c>StartPowerBarBuild</c> (0x0056ADB0), <c>GetPowerBarLevel</c>
/// (0x0056ADE0), <c>StartAttackRequest</c> (0x0056C040),
/// <c>EndAttackRequest</c> (0x0056C0E0), <c>UseTime</c> (0x0056C1F0),
/// <c>HandleAttackDoneEvent</c> (0x0056C500), and
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0), and
/// <c>AbortAutomaticAttack</c> (0x0056AE90).
/// </remarks>
public sealed class RuntimeCombatAttackState : IDisposable
{
public const double AttackPowerUpSeconds = CombatInputPlanner.AttackPowerUpSeconds;
public const double DualWieldPowerUpSeconds = CombatInputPlanner.DualWieldPowerUpSeconds;
public const float InitialDesiredPower = 0.5f;
public const float DesiredPowerStep = 1f / 6f;
private readonly CombatState _combat;
private readonly IRuntimeCombatAttackOperations _operations;
private readonly Func<double> _now;
private bool _buildInProgress;
private double _buildStartTime;
private bool _attackRequestInProgress;
private bool _attackServerResponsePending;
private bool _attackWhenResponseReceived;
private float _attackWhenResponseReceivedPower;
private bool _currentBuildIsAutomatic;
private bool _repeatAttacking;
private float _requestedAttackPower;
private float _latestPowerBarLevel;
private bool _disposed;
public RuntimeCombatAttackState(
CombatState combat,
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
Action? prepareAttackRequest = null,
Action? sendCancelAttack = null,
Func<bool>? isDualWield = null,
Func<bool>? playerReadyForAttack = null,
Func<bool>? autoRepeatAttack = null,
Func<double>? now = null)
: this(
combat,
new DelegateRuntimeCombatAttackOperations(
canStartAttack,
sendAttack,
prepareAttackRequest,
sendCancelAttack,
isDualWield,
playerReadyForAttack,
autoRepeatAttack),
now)
{
}
public RuntimeCombatAttackState(
CombatState combat,
IRuntimeCombatAttackOperations operations,
Func<double>? now = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
_combat.CombatModeChanged += OnCombatModeChanged;
_combat.AttackCommenced += OnAttackCommenced;
_combat.AttackDone += OnAttackDone;
}
public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
public float DesiredPower { get; private set; } = InitialDesiredPower;
public bool AttackRequestInProgress => _attackRequestInProgress;
public float RequestedAttackPower => _requestedAttackPower;
public bool BuildInProgress => _buildInProgress;
public bool IsDisposed => _disposed;
/// <summary>The level retail publishes to the embedded combat meter.</summary>
public float PowerBarLevel => _buildInProgress
? GetPowerBarLevel()
: _latestPowerBarLevel;
public event Action? StateChanged;
public bool HandleCommand(in RuntimeCombatAttackInput command)
{
AttackHeight? height = command.Command switch
{
RuntimeCombatAttackCommand.LowAttack => AttackHeight.Low,
RuntimeCombatAttackCommand.MediumAttack => AttackHeight.Medium,
RuntimeCombatAttackCommand.HighAttack => AttackHeight.High,
_ => null,
};
if (height is not null)
{
if (command.Activation == RuntimeInputActivation.Press)
PressAttack(height.Value);
else if (command.Activation == RuntimeInputActivation.Release)
ReleaseAttack();
return true;
}
if (command.Activation != RuntimeInputActivation.Press)
return false;
if (command.Command == RuntimeCombatAttackCommand.DecreasePower)
{
StepDesiredPower(-1);
return true;
}
if (command.Command == RuntimeCombatAttackCommand.IncreasePower)
{
StepDesiredPower(1);
return true;
}
if (command.Command == RuntimeCombatAttackCommand.AbortForMovement)
{
AbortAutomaticAttack();
return true;
}
return false;
}
/// <summary>
/// Routes the semantic inputs that retail turns into a new forward-motion
/// command. Turns and sidesteps live in separate command lists and do not
/// call <c>HandleNewForwardMovement</c>. Jump has its own equivalent cancel
/// in <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
/// </summary>
public void SetDesiredPower(float power)
{
float value = Math.Clamp(power, 0f, 1f);
if (Math.Abs(DesiredPower - value) < float.Epsilon)
return;
DesiredPower = value;
StateChanged?.Invoke();
}
public void PressAttack(AttackHeight height)
{
bool heightChanged = RequestedHeight != height;
RequestedHeight = height;
if (heightChanged || !_attackRequestInProgress)
StartAttackRequest();
StateChanged?.Invoke();
}
public void ReleaseAttack()
{
if (!_attackRequestInProgress)
return;
_attackRequestInProgress = false;
float currentPower = GetPowerBarLevel();
_requestedAttackPower = Math.Min(DesiredPower, currentPower);
if (_attackServerResponsePending)
{
_attackWhenResponseReceived = true;
_attackWhenResponseReceivedPower = _requestedAttackPower;
}
else if (DesiredPower <= currentPower || _repeatAttacking)
{
ExecuteAttack(RequestedHeight, setServerPending: true);
}
StateChanged?.Invoke();
}
/// <summary>
/// Retail <c>ClientCombatSystem::AbortAutomaticAttack</c> (0x0056AE90).
/// A new forward-movement command enters here through
/// <c>ACCmdInterp::HandleNewForwardMovement</c> (0x0058B1F0): notify the
/// server, end repeat mode, and hide an in-progress combat power build.
/// Request/pending flags are deliberately left for the normal server
/// response, matching retail.
/// </summary>
public void AbortAutomaticAttack()
{
if (!_attackServerResponsePending
&& !_attackRequestInProgress
&& !_repeatAttacking)
return;
_operations.SendCancelAttack();
_repeatAttacking = false;
if (_buildInProgress)
ResetPowerBar();
StateChanged?.Invoke();
}
/// <summary>
/// Retail <c>UseTime</c>: publishes the live build and commits a released
/// request once the ticking level reaches the power captured on release.
/// </summary>
public void Tick()
{
if (_attackRequestInProgress
&& !_buildInProgress
&& !_attackServerResponsePending)
AttemptStartBuildingAttack();
if (!_buildInProgress)
return;
if (!_operations.PlayerReadyForAttack)
{
if (_attackRequestInProgress)
{
StopBuild();
_latestPowerBarLevel = 0f;
}
else
{
_repeatAttacking = false;
ResetPowerBar();
}
StateChanged?.Invoke();
return;
}
float currentPower = GetPowerBarLevel();
_latestPowerBarLevel = currentPower;
if (!_attackRequestInProgress
&& currentPower >= _requestedAttackPower)
{
_latestPowerBarLevel = Math.Min(_requestedAttackPower, currentPower);
if (!_currentBuildIsAutomatic)
ExecuteAttack(RequestedHeight, setServerPending: true);
else
StopBuild();
}
StateChanged?.Invoke();
}
private void StepDesiredPower(int direction)
{
int sixth = (int)MathF.Round(DesiredPower / DesiredPowerStep);
SetDesiredPower((sixth + Math.Sign(direction)) * DesiredPowerStep);
}
private void StartAttackRequest()
{
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|| !_operations.CanStartAttack())
return;
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
// and requested power first, then FinishJump and
// CommandInterpreter::MaybeStopCompletely. The host callback owns the
// player movement object and must run before any later attack send.
_attackRequestInProgress = true;
_requestedAttackPower = 1f;
_operations.PrepareAttackRequest();
_currentBuildIsAutomatic = false;
AttemptStartBuildingAttack();
}
private void AttemptStartBuildingAttack()
{
if (_buildInProgress
|| _attackServerResponsePending
|| !_operations.PlayerReadyForAttack)
return;
StartPowerBarBuild();
}
private void StartPowerBarBuild()
{
_buildInProgress = true;
_buildStartTime = _now();
_latestPowerBarLevel = 0f;
}
private float GetPowerBarLevel()
{
if (!_buildInProgress)
return 0f;
double duration = _operations.IsDualWield
? DualWieldPowerUpSeconds
: AttackPowerUpSeconds;
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
}
private void ExecuteAttack(AttackHeight height, bool setServerPending)
{
StopBuild();
if (!_operations.SendAttack(
height,
Math.Clamp(_requestedAttackPower, 0f, 1f)))
{
ResetPowerBar();
return;
}
if (_operations.AutoRepeatAttack)
_repeatAttacking = true;
_attackServerResponsePending = setServerPending;
}
private void OnAttackCommenced()
{
_attackServerResponsePending = true;
if (!_attackRequestInProgress)
{
_latestPowerBarLevel = _requestedAttackPower;
StopBuild(preserveLevel: true);
}
StateChanged?.Invoke();
}
private void OnAttackDone(uint _, uint weenieError)
{
_attackServerResponsePending = false;
if (weenieError != 0)
_repeatAttacking = false;
if (!_attackRequestInProgress
&& _operations.AutoRepeatAttack
&& _repeatAttacking)
{
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
_requestedAttackPower = DesiredPower;
ExecuteAttack(RequestedHeight, setServerPending: false);
}
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
{
_repeatAttacking = false;
ResetPowerBar();
}
else if (_attackRequestInProgress)
{
AttemptStartBuildingAttack();
}
else
{
StartPowerBarBuild();
_currentBuildIsAutomatic = true;
}
if (_attackWhenResponseReceived)
{
float queuedPower = _attackWhenResponseReceivedPower;
_attackWhenResponseReceived = false;
_attackWhenResponseReceivedPower = 0f;
StartAttackRequest();
if (_attackRequestInProgress)
{
_requestedAttackPower = queuedPower;
_attackRequestInProgress = false;
ExecuteAttack(RequestedHeight, setServerPending: true);
}
}
StateChanged?.Invoke();
}
private void OnCombatModeChanged(CombatMode mode)
{
if (!CombatInputPlanner.SupportsTargetedAttack(mode))
Reset();
StateChanged?.Invoke();
}
private void StopBuild(bool preserveLevel = false)
{
if (!preserveLevel && _buildInProgress)
_latestPowerBarLevel = GetPowerBarLevel();
_buildInProgress = false;
_buildStartTime = 0d;
}
private void ResetPowerBar()
{
StopBuild();
_latestPowerBarLevel = 0f;
_currentBuildIsAutomatic = false;
}
private void Reset()
{
_attackRequestInProgress = false;
_attackServerResponsePending = false;
_attackWhenResponseReceived = false;
_attackWhenResponseReceivedPower = 0f;
_repeatAttacking = false;
_requestedAttackPower = 0f;
ResetPowerBar();
}
/// <summary>
/// Restore the process-lived attack controller to retail's new-session
/// defaults without sending a cancel packet through the displaced session.
/// Mirrors <c>ClientCombatSystem::Begin @ 0x0056A460</c>.
/// </summary>
public void ResetSession()
{
Reset();
RequestedHeight = AttackHeight.Medium;
DesiredPower = InitialDesiredPower;
StateChanged?.Invoke();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_combat.CombatModeChanged -= OnCombatModeChanged;
_combat.AttackCommenced -= OnAttackCommenced;
_combat.AttackDone -= OnAttackDone;
}
}

View file

@ -0,0 +1,94 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay;
public enum RuntimeCombatModeRequestStatus
{
Inactive,
Rejected,
Sent,
}
public readonly record struct RuntimeCombatModeRequestResult(
RuntimeCombatModeRequestStatus Status,
CombatMode Mode,
string? Notice = null);
public interface IRuntimeCombatModeOperations
{
bool IsInWorld { get; }
IReadOnlyList<ClientObject> GetOrderedEquipment();
void NotifyExplicitCombatModeRequest();
void SendChangeCombatMode(CombatMode mode);
}
/// <summary>
/// Presentation-independent owner for retail's explicit combat-mode command.
/// App supplies ordered equipment, transport, and the auto-wield cancellation
/// edge; Runtime owns the exact default selection and local state transition.
/// </summary>
/// <remarks>
/// Port of <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c>
/// and <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>.
/// </remarks>
public sealed class RuntimeCombatModeState
{
private readonly CombatState _combat;
private readonly IRuntimeCombatModeOperations _operations;
public RuntimeCombatModeState(
CombatState combat,
IRuntimeCombatModeOperations operations)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_operations = operations
?? throw new ArgumentNullException(nameof(operations));
}
public RuntimeCombatModeRequestResult Toggle()
{
if (!_operations.IsInWorld)
{
return new RuntimeCombatModeRequestResult(
RuntimeCombatModeRequestStatus.Inactive,
_combat.CurrentMode);
}
// Every explicit user request supersedes auto-wield settlement,
// including a request GetDefaultCombatMode later rejects.
_operations.NotifyExplicitCombatModeRequest();
CombatMode currentMode = _combat.CurrentMode;
CombatMode nextMode;
if (currentMode != CombatMode.NonCombat)
{
nextMode = CombatMode.NonCombat;
}
else
{
DefaultCombatModeDecision decision =
CombatInputPlanner.GetDefaultCombatModeDecision(
_operations.GetOrderedEquipment());
if (decision.IncompatibleHeldItem is { } held)
{
string notice =
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
return new RuntimeCombatModeRequestResult(
RuntimeCombatModeRequestStatus.Rejected,
currentMode,
notice);
}
nextMode = CombatInputPlanner.ToggleMode(
currentMode,
decision.Mode);
}
_operations.SendChangeCombatMode(nextMode);
_combat.SetCombatMode(nextMode);
return new RuntimeCombatModeRequestResult(
RuntimeCombatModeRequestStatus.Sent,
nextMode);
}
}

View file

@ -0,0 +1,77 @@
using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
namespace AcDream.Runtime.Gameplay;
public interface IRuntimeCombatTargetOperations
{
bool AutoTarget { get; }
uint? SelectClosestTarget();
}
/// <summary>
/// Owns combat-target lifecycle around the shared selection state.
/// </summary>
/// <remarks>
/// Retail <c>ClientCombatSystem::RecvNotice_SelectionChanged</c>
/// (0x0056BD80) invokes <c>AutoTarget</c> when selection becomes zero while
/// Auto Target is enabled in Melee/Missile combat. Authoritative Dead motion
/// makes the selected combat object unavailable and enters that same notice
/// path.
/// </remarks>
public sealed class RuntimeCombatTargetState : IDisposable
{
private readonly CombatState _combat;
private readonly SelectionState _selection;
private readonly IRuntimeCombatTargetOperations _operations;
private bool _disposed;
public bool IsDisposed => _disposed;
public RuntimeCombatTargetState(
CombatState combat,
SelectionState selection,
IRuntimeCombatTargetOperations operations)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_operations = operations
?? throw new ArgumentNullException(nameof(operations));
_selection.Changed += OnSelectionChanged;
}
/// <summary>
/// Called after an accepted UpdateMotion has reached the entity's motion
/// table. Dead is a persistent substate, so CurrentMotion is the
/// authoritative availability signal rather than a damage prediction.
/// </summary>
public void OnMotionApplied(uint objectId, uint currentMotion)
{
if (currentMotion != MotionCommand.Dead
|| _selection.SelectedObjectId != objectId)
return;
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.CombatTargetDied);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_selection.Changed -= OnSelectionChanged;
}
private void OnSelectionChanged(SelectionTransition transition)
{
if (transition.SelectedObjectId is not null
|| transition.Reason == SelectionChangeReason.SessionReset
|| !_operations.AutoTarget
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
return;
_operations.SelectClosestTarget();
}
}

View file

@ -0,0 +1,161 @@
using System;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay;
public interface IRuntimeSpellCastOperations
{
uint LocalPlayerId { get; }
bool CanSend { get; }
bool HasRequiredComponents(uint spellId);
bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage);
void StopCompletely();
void SendUntargeted(uint spellId);
void SendTargeted(uint targetId, uint spellId);
void DisplayMessage(string message);
void IncrementBusy();
}
/// <summary>
/// Runtime owner for retail cast intent. It validates the canonical spellbook
/// and selection, stops movement, and emits exactly one targeted or untargeted
/// request. ACE owns every gameplay result after that boundary.
/// </summary>
/// <remarks>
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
/// does not consume mana/components or start local effects.
/// </remarks>
public sealed class RuntimeSpellCastState
{
private readonly Spellbook _spellbook;
private readonly SelectionState _selection;
private readonly IRuntimeSpellCastOperations _operations;
public RuntimeSpellCastState(
Spellbook spellbook,
SelectionState selection,
IRuntimeSpellCastOperations operations)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
public event Action? StateChanged;
public bool IsTargetReady(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selection.SelectedObjectId is uint target and not 0u
&& _operations.IsTargetCompatible(
target,
spell,
showMessage: false);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_operations.DisplayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_operations.HasRequiredComponents(spellId))
{
_operations.DisplayMessage(
"You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _operations.LocalPlayerId;
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selection.SelectedObjectId;
untargeted = false;
if (target is null or 0)
{
_operations.DisplayMessage(
"You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_operations.IsTargetCompatible(
target.Value,
spell,
showMessage: true))
return CastRequestResult.IncompatibleTarget;
}
if (!_operations.CanSend)
{
_operations.DisplayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_operations.StopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_operations.SendUntargeted(spellId);
else
_operations.SendTargeted(target!.Value, spellId);
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
// busy reference only after Event_Cast has been emitted. The
// matching server UseDone event decrements it.
_operations.IncrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
StateChanged?.Invoke();
return CastRequestResult.Sent;
}
public void Reset()
{
bool changed = LastRequestedSpellId is not null
|| LastRequestedTargetId is not null;
LastRequestedSpellId = null;
LastRequestedTargetId = null;
if (changed)
StateChanged?.Invoke();
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}