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

@ -1,6 +1,7 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Combat;
@ -54,11 +55,12 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
public void Show(string message) => _viewModel?.AddToast(message);
}
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
internal sealed class CombatAttackOperationsSlot
: IRuntimeCombatAttackOperations
{
private ICombatAttackOperations? _owner;
private IRuntimeCombatAttackOperations? _owner;
public void Bind(ICombatAttackOperations owner)
public void Bind(IRuntimeCombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null && !ReferenceEquals(_owner, owner))
@ -67,7 +69,7 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
_owner = owner;
}
public IDisposable BindOwned(ICombatAttackOperations owner)
public IDisposable BindOwned(IRuntimeCombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
@ -77,7 +79,7 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
return new Binding(this, owner);
}
private void Unbind(ICombatAttackOperations expected)
private void Unbind(IRuntimeCombatAttackOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
@ -95,11 +97,11 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
private sealed class Binding : IDisposable
{
private CombatAttackOperationsSlot? _slot;
private readonly ICombatAttackOperations _expected;
private readonly IRuntimeCombatAttackOperations _expected;
public Binding(
CombatAttackOperationsSlot slot,
ICombatAttackOperations expected)
IRuntimeCombatAttackOperations expected)
{
_slot = slot;
_expected = expected;
@ -114,7 +116,8 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
/// Production combat request dependencies. The attack state machine retains
/// this typed owner rather than callbacks into the application window.
/// </summary>
internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
internal sealed class LiveCombatAttackOperations
: IRuntimeCombatAttackOperations
{
private readonly CombatState _combat;
private readonly ICombatAttackTargetSource _targets;

View file

@ -3,6 +3,7 @@ using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.App.Combat;
@ -164,37 +165,96 @@ internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
}
}
/// <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
internal sealed class RuntimeCombatModeOperationsSlot
: IRuntimeCombatModeOperations
{
private IRuntimeCombatModeOperations? _owner;
public IDisposable BindOwned(IRuntimeCombatModeOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
throw new InvalidOperationException(
"Runtime combat-mode operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(IRuntimeCombatModeOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
public bool IsInWorld => _owner?.IsInWorld == true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_owner?.GetOrderedEquipment() ?? [];
public void NotifyExplicitCombatModeRequest() =>
_owner?.NotifyExplicitCombatModeRequest();
public void SendChangeCombatMode(CombatMode mode) =>
_owner?.SendChangeCombatMode(mode);
private sealed class Binding : IDisposable
{
private RuntimeCombatModeOperationsSlot? _slot;
private readonly IRuntimeCombatModeOperations _expected;
public Binding(
RuntimeCombatModeOperationsSlot slot,
IRuntimeCombatModeOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
internal sealed class LiveCombatModeOperations
: IRuntimeCombatModeOperations
{
private readonly ILiveCombatModeAuthority _authority;
private readonly ICombatEquipmentSource _equipment;
private readonly CombatState _combat;
private readonly IExplicitCombatModeIntentSink _itemIntent;
public LiveCombatModeOperations(
ILiveCombatModeAuthority authority,
ICombatEquipmentSource equipment,
IExplicitCombatModeIntentSink itemIntent)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
}
public bool IsInWorld => _authority.IsInWorld;
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_equipment.GetOrderedEquipment();
public void NotifyExplicitCombatModeRequest() =>
_itemIntent.NotifyExplicitCombatModeRequest();
public void SendChangeCombatMode(CombatMode mode) =>
_authority.SendChangeCombatMode(mode);
}
/// <summary>
/// App presentation adapter for Runtime's retail combat-mode decision.
/// </summary>
internal sealed class RuntimeCombatModeCommandAdapter : ILiveCombatModeCommand
{
private readonly RuntimeCombatModeState _owner;
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,
public RuntimeCombatModeCommandAdapter(
RuntimeCombatModeState owner,
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));
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
_log = log ?? (_ => { });
_toast = toast;
_systemMessage = systemMessage ?? (_ => { });
@ -202,48 +262,19 @@ internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
public void Toggle()
{
if (!_authority.IsInWorld)
RuntimeCombatModeRequestResult result = _owner.Toggle();
if (result.Status == RuntimeCombatModeRequestStatus.Inactive)
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)
if (result.Status == RuntimeCombatModeRequestStatus.Rejected)
{
// 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;
}
string notice = result.Notice ?? string.Empty;
_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}";
string message = $"Combat mode {result.Mode}";
_log($"combat: {message}");
_toast?.Invoke(message);
}

View file

@ -0,0 +1,63 @@
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Combat;
/// <summary>
/// Early Runtime construction seam for the App-owned closest-hostile query.
/// The slot owns no target identity or selection state.
/// </summary>
internal sealed class RuntimeCombatTargetOperationsSlot
: IRuntimeCombatTargetOperations
{
private IRuntimeCombatTargetOperations? _owner;
public IDisposable BindOwned(IRuntimeCombatTargetOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
throw new InvalidOperationException(
"Runtime combat-target operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(IRuntimeCombatTargetOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
public bool AutoTarget => _owner?.AutoTarget == true;
public uint? SelectClosestTarget() => _owner?.SelectClosestTarget();
private sealed class Binding : IDisposable
{
private RuntimeCombatTargetOperationsSlot? _slot;
private readonly IRuntimeCombatTargetOperations _expected;
public Binding(
RuntimeCombatTargetOperationsSlot slot,
IRuntimeCombatTargetOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
internal sealed class LiveCombatTargetOperations(
Func<bool> autoTarget,
Func<uint?> selectClosestTarget)
: IRuntimeCombatTargetOperations
{
private readonly Func<bool> _autoTarget = autoTarget
?? throw new ArgumentNullException(nameof(autoTarget));
private readonly Func<uint?> _selectClosestTarget = selectClosestTarget
?? throw new ArgumentNullException(nameof(selectClosestTarget));
public bool AutoTarget => _autoTarget();
public uint? SelectClosestTarget() => _selectClosestTarget();
}

View file

@ -45,7 +45,9 @@ internal sealed record InteractionRetainedUiDependencies(
InputDispatcher? InputDispatcher,
RuntimeSettingsController Settings,
RuntimeActionState Actions,
ICombatAttackOperations CombatAttackOperations,
IRuntimeCombatAttackOperations CombatAttackOperations,
RuntimeCombatTargetOperationsSlot CombatTargetOperations,
RuntimeSpellCastOperationsSlot SpellCastOperations,
RuntimeInventoryState Inventory,
MagicCatalog MagicCatalog,
RuntimeCharacterState Character,
@ -70,14 +72,13 @@ internal sealed record RetainedUiComposition(
VitalsVM Vitals,
ChatVM Chat,
CharacterSheetProvider CharacterSheet,
MagicRuntime Magic,
FrameScreenshotController? Screenshots);
internal sealed record InteractionRetainedUiResult(
CombatAttackController CombatAttack,
CombatTargetController CombatTarget,
RuntimeCombatAttackState CombatAttack,
ExternalContainerLifecycleController ExternalContainerLifecycle,
ItemInteractionController ItemInteraction,
MagicRuntime Magic,
RetainedUiComposition? RetainedUi,
InteractionUiLateBindings LateBindings);
@ -89,16 +90,15 @@ internal interface IGameWindowInteractionRetainedUiPublication
internal enum InteractionRetainedUiCompositionPoint
{
LateBindingsCreated,
CombatAttackCreated,
CombatTargetCreated,
ExternalContainerLifecycleCreated,
ItemInteractionCreated,
MagicRuntimeCreated,
RetainedUiDisabled,
UiHostAcquired,
InputCaptureBound,
CursorAssetsCreated,
CharacterSheetCreated,
MagicRuntimeCreated,
MouseInputWired,
KeyboardInputWired,
UiAssetsCreated,
@ -225,10 +225,7 @@ internal sealed class InteractionUiLateBindings : IDisposable
internal interface IInteractionRetainedUiCompositionFactory
{
CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies dependencies);
CombatTargetController CreateCombatTarget(
IDisposable BindCombatTarget(
InteractionRetainedUiDependencies dependencies,
DeferredSelectionUiAuthority selection);
@ -240,12 +237,18 @@ internal interface IInteractionRetainedUiCompositionFactory
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings);
MagicRuntime CreateMagicRuntime(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
ItemInteractionController itemInteraction);
RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
RetailUiRuntimeLease lease,
CombatAttackController combatAttack,
RuntimeCombatAttackState combatAttack,
ItemInteractionController itemInteraction,
MagicRuntime magic,
Action<InteractionRetainedUiCompositionPoint> checkpoint);
void Release(IDisposable resource);
@ -254,19 +257,13 @@ internal interface IInteractionRetainedUiCompositionFactory
internal sealed class RetailInteractionRetainedUiCompositionFactory
: IInteractionRetainedUiCompositionFactory
{
public CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies d) =>
new(d.Actions.Combat, d.CombatAttackOperations);
public CombatTargetController CreateCombatTarget(
public IDisposable BindCombatTarget(
InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) =>
new(
d.Actions.Combat,
d.Actions.Selection,
d.CombatTargetOperations.BindOwned(new LiveCombatTargetOperations(
autoTarget: () => d.Settings.Gameplay.AutoTarget,
selectClosestTarget: () =>
selection.SelectClosestCombatTarget(showToast: false));
selection.SelectClosestCombatTarget(showToast: false)));
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies d,
@ -339,12 +336,38 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
requestUse: selection.RequestUse);
}
public MagicRuntime CreateMagicRuntime(
InteractionRetainedUiDependencies d,
InteractionUiLateBindings late,
ItemInteractionController itemInteraction) =>
MagicRuntime.Create(
d.MagicCatalog,
d.Actions.SpellCast,
d.SpellCastOperations,
d.Inventory.Objects,
localPlayerId: () => d.PlayerIdentity.ServerGuid,
accountName: () => late.Session.AccountName
?? d.Options.LiveUser
?? string.Empty,
stopCompletely: d.CombatAttackOperations.PrepareAttackRequest,
sendUntargeted: spellId =>
late.Session.CurrentSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) =>
late.Session.CurrentSession?.SendCastTargetedSpell(
target,
spellId),
displayMessage:
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: itemInteraction.IncrementBusyCount,
canSend: () => late.Session.IsInWorld);
public RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies d,
InteractionUiLateBindings late,
RetailUiRuntimeLease lease,
CombatAttackController combatAttack,
RuntimeCombatAttackState combatAttack,
ItemInteractionController itemInteraction,
MagicRuntime magic,
Action<InteractionRetainedUiCompositionPoint> checkpoint)
{
IDisposable? inputCapture = null;
@ -404,27 +427,6 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
credits));
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
MagicRuntime magic = MagicRuntime.Create(
d.MagicCatalog,
d.Character.Spellbook,
d.Inventory.Objects,
selectedObject: () =>
d.Actions.Selection.SelectedObjectId,
localPlayerId: () => d.PlayerIdentity.ServerGuid,
accountName: () => late.Session.AccountName
?? d.Options.LiveUser
?? string.Empty,
stopCompletely: d.CombatAttackOperations.PrepareAttackRequest,
sendUntargeted: spellId =>
late.Session.CurrentSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) =>
late.Session.CurrentSession?.SendCastTargetedSpell(target, spellId),
displayMessage:
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: itemInteraction.IncrementBusyCount,
canSend: () => late.Session.IsInWorld);
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
uint MagicSkillLevel(MagicSchool school)
{
static uint SkillId(MagicSchool value) => value switch
@ -717,7 +719,6 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
vitals,
chat,
characterSheet,
magic,
screenshots);
}
catch (Exception failure)
@ -805,15 +806,12 @@ internal sealed class InteractionRetainedUiCompositionPhase
_dependencies.SelectionCameraFactory(late.SelectionViewPlane));
Fault(InteractionRetainedUiCompositionPoint.LateBindingsCreated);
var attackLease = scope.Acquire(
"combat attack controller",
() => _factory.CreateCombatAttack(_dependencies),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.CombatAttackCreated);
var targetLease = scope.Acquire(
"combat target controller",
() => _factory.CreateCombatTarget(_dependencies, late.Selection),
_factory.Release);
IDisposable combatTargetBinding = _factory.BindCombatTarget(
_dependencies,
late.Selection);
late.AdoptLateOwnerBinding(
"combat-target operations",
combatTargetBinding);
Fault(InteractionRetainedUiCompositionPoint.CombatTargetCreated);
var externalLease = scope.Acquire(
"external container lifecycle",
@ -827,6 +825,14 @@ internal sealed class InteractionRetainedUiCompositionPhase
() => _factory.CreateItemInteraction(_dependencies, late),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.ItemInteractionCreated);
var magicLease = scope.Acquire(
"magic runtime",
() => _factory.CreateMagicRuntime(
_dependencies,
late,
itemLease.Resource),
_factory.Release);
Fault(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
var uiLease = scope.Own(
"retained UI runtime lease",
@ -839,8 +845,9 @@ internal sealed class InteractionRetainedUiCompositionPhase
_dependencies,
late,
_retainedUiLease,
attackLease.Resource,
_dependencies.Actions.CombatAttack,
itemLease.Resource,
magicLease.Resource,
Fault);
}
else
@ -849,18 +856,17 @@ internal sealed class InteractionRetainedUiCompositionPhase
}
var result = new InteractionRetainedUiResult(
attackLease.Resource,
targetLease.Resource,
_dependencies.Actions.CombatAttack,
externalLease.Resource,
itemLease.Resource,
magicLease.Resource,
retainedUi,
late);
_publication.PublishInteractionRetainedUi(result);
lateLease.Transfer();
attackLease.Transfer();
targetLease.Transfer();
externalLease.Transfer();
itemLease.Transfer();
magicLease.Transfer();
uiLease.Transfer();
Fault(InteractionRetainedUiCompositionPoint.ResultPublished);
scope.Complete();

View file

@ -43,6 +43,7 @@ internal sealed record SessionPlayerDependencies(
WorldSceneDebugState WorldSceneDebugState,
RuntimeDiagnosticCommandSlot RuntimeDiagnosticCommands,
LiveCombatModeCommandSlot CombatModeCommands,
RuntimeCombatModeOperationsSlot CombatModeOperations,
HostQuiescenceGate HostQuiescence,
PhysicsEngine PhysicsEngine,
PhysicsDataCache PhysicsDataCache,
@ -514,7 +515,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PhysicsEngine,
content.Dats,
content.AnimationLoader,
interaction.CombatTarget,
d.Actions.CombatTarget,
d.WorldOrigin,
d.TeleportSink,
d.PlayerController,
@ -804,7 +805,7 @@ internal sealed class SessionPlayerCompositionPhase
interaction.RetainedUi?.Runtime,
vitals,
interaction.RetainedUi?.CharacterSheet,
interaction.RetainedUi?.Magic,
interaction.Magic,
live.PaperdollPresenter),
new LiveSessionInteractionRuntime(
d.Settings,
@ -850,14 +851,18 @@ internal sealed class SessionPlayerCompositionPhase
Action<string>? debugToast = d.SettingsDevTools.DevTools is { } devOwner
? message => devOwner.Debug.AddToast(message)
: null;
var combatCommand = new LiveCombatModeCommandController(
var combatModeOperations = new LiveCombatModeOperations(
new LiveSessionCombatModeAuthority(sessionHost),
new LocalPlayerCombatEquipmentSource(
d.EntityObjects.Objects,
d.PlayerIdentity),
d.Actions.Combat,
new ItemInteractionCombatModeIntentSink(
interaction.ItemInteraction),
interaction.ItemInteraction));
bindings.Adopt(
"runtime combat-mode operations",
d.CombatModeOperations.BindOwned(combatModeOperations));
var combatCommand = new RuntimeCombatModeCommandAdapter(
d.Actions.CombatMode,
d.Log,
debugToast,
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au));
@ -879,8 +884,7 @@ internal sealed class SessionPlayerCompositionPhase
worldReveal,
d.UpdateClock,
live.SelectionInteractions,
gameplayInput,
combatCommand);
gameplayInput);
bindings.Adopt("current game runtime adapter", gameRuntime);
bindings.Adopt(
"retained-UI game runtime commands",

View file

@ -1,5 +1,6 @@
using AcDream.App.Combat;
using AcDream.App.Update;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
@ -13,18 +14,55 @@ internal interface ICombatInputFrameController
internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController
{
private readonly CombatAttackController _owner;
private readonly RuntimeCombatAttackState _owner;
public CombatAttackInputFrameAdapter(CombatAttackController owner) =>
public CombatAttackInputFrameAdapter(RuntimeCombatAttackState owner) =>
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
public void Tick() => _owner.Tick();
public void HandleMovementInput(InputAction action, ActivationType activation) =>
_owner.HandleMovementInput(action, activation);
public void HandleMovementInput(InputAction action, ActivationType activation)
{
if (activation != ActivationType.Press
|| action is not (
InputAction.MovementForward
or InputAction.MovementBackup
or InputAction.MovementRunLock
or InputAction.MovementJump))
{
return;
}
public bool HandleInputAction(InputAction action, ActivationType activation) =>
_owner.HandleInputAction(action, activation);
_owner.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.AbortForMovement,
RuntimeInputActivation.Press));
}
public bool HandleInputAction(InputAction action, ActivationType activation)
{
RuntimeCombatAttackCommand? command = action switch
{
InputAction.CombatLowAttack =>
RuntimeCombatAttackCommand.LowAttack,
InputAction.CombatMediumAttack =>
RuntimeCombatAttackCommand.MediumAttack,
InputAction.CombatHighAttack =>
RuntimeCombatAttackCommand.HighAttack,
InputAction.CombatDecreaseAttackPower =>
RuntimeCombatAttackCommand.DecreasePower,
InputAction.CombatIncreaseAttackPower =>
RuntimeCombatAttackCommand.IncreasePower,
_ => null,
};
if (command is null)
return false;
return _owner.HandleCommand(new RuntimeCombatAttackInput(
command.Value,
activation == ActivationType.Press
? RuntimeInputActivation.Press
: RuntimeInputActivation.Release));
}
}
/// <summary>

View file

@ -58,7 +58,7 @@ internal sealed record LiveSessionInteractionRuntime(
PlayerModeController PlayerMode,
PlayerModeAutoEntry PlayerModeAutoEntry,
ItemInteractionController ItemInteraction,
CombatAttackController CombatAttack,
RuntimeCombatAttackState CombatAttack,
SelectionInteractionController SelectionInteractions);
internal sealed record LiveSessionWorldRuntime(

View file

@ -13,6 +13,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter;
@ -48,7 +49,7 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly PhysicsEngine _physicsEngine;
private readonly IDatReaderWriter _dats;
private readonly IAnimationLoader _animLoader;
private readonly CombatTargetController? _combatTargetController;
private readonly RuntimeCombatTargetState? _combatTargetController;
private readonly LiveWorldOriginState _origin;
private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink
_localPlayerTeleport;
@ -90,7 +91,7 @@ internal sealed class LiveEntityNetworkUpdateController
PhysicsEngine physicsEngine,
IDatReaderWriter dats,
IAnimationLoader animLoader,
CombatTargetController? combatTargetController,
RuntimeCombatTargetState? combatTargetController,
LiveWorldOriginState origin,
AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
ILocalPlayerControllerSource playerControllerSource,

View file

@ -373,10 +373,15 @@ public sealed class GameWindow :
private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new();
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.RuntimeCombatTargetOperationsSlot
_combatTargetOperations = new();
private readonly AcDream.App.Combat.RuntimeCombatModeOperationsSlot
_combatModeOperations = new();
private readonly AcDream.App.Spells.RuntimeSpellCastOperationsSlot
_spellCastOperations = new();
private readonly AcDream.App.Combat.CombatFeedbackSlot
_combatFeedback = new();
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private RuntimeCombatAttackState? _combatAttackController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
@ -561,8 +566,14 @@ public sealed class GameWindow :
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);
_runtimeCharacter = new RuntimeCharacterState();
_runtimeActions = new RuntimeActionState(
_runtimeInventory.Transactions,
_runtimeCharacter.Spellbook,
_combatAttackOperations,
_combatTargetOperations,
_combatModeOperations,
_spellCastOperations);
var alphaScratchBudgets =
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
_options.ResidencyBudgets.AlphaScratchBytes);
@ -905,7 +916,6 @@ public sealed class GameWindow :
{
ArgumentNullException.ThrowIfNull(result);
if (_combatAttackController is not null
|| _combatTargetController is not null
|| _externalContainerLifecycle is not null
|| _itemInteractionController is not null
|| _interactionUiLateBindings is not null
@ -921,10 +931,10 @@ public sealed class GameWindow :
}
_combatAttackController = result.CombatAttack;
_combatTargetController = result.CombatTarget;
_externalContainerLifecycle = result.ExternalContainerLifecycle;
_itemInteractionController = result.ItemInteraction;
_interactionUiLateBindings = result.LateBindings;
_magicRuntime = result.Magic;
if (result.RetainedUi is { } retained)
{
_uiHost = retained.Host;
@ -932,7 +942,6 @@ public sealed class GameWindow :
_vitalsVm ??= retained.Vitals;
_retailChatVm = retained.Chat;
_characterSheetProvider = retained.CharacterSheet;
_magicRuntime = retained.Magic;
_frameScreenshots = retained.Screenshots;
}
}
@ -1256,6 +1265,8 @@ public sealed class GameWindow :
_runtimeSettings,
_runtimeActions,
_combatAttackOperations,
_combatTargetOperations,
_spellCastOperations,
_runtimeInventory,
contentEffectsAudio.MagicCatalog,
_runtimeCharacter,
@ -1358,6 +1369,7 @@ public sealed class GameWindow :
_worldSceneDebugState,
_runtimeDiagnosticCommands,
_liveCombatModeCommands,
_combatModeOperations,
_hostQuiescence,
_physicsEngine,
_physicsDataCache,
@ -1564,8 +1576,7 @@ public sealed class GameWindow :
new LiveShutdownRoots(
_cameraPointerInput,
_retailUiLease,
_combatTargetController,
_combatAttackController,
_magicRuntime,
_itemInteractionController,
_externalContainerLifecycle,
_streamer,

View file

@ -10,6 +10,7 @@ using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Spells;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.App.World;
@ -76,8 +77,7 @@ internal sealed record FrameShutdownRoots(
internal sealed record LiveShutdownRoots(
CameraPointerInputController? CameraPointer,
RetailUiRuntimeLease RetailUi,
CombatTargetController? CombatTarget,
CombatAttackController? CombatAttack,
MagicRuntime? Magic,
ItemInteractionController? ItemInteraction,
ExternalContainerLifecycleController? ExternalContainers,
LandblockStreamer? Streamer,
@ -377,8 +377,7 @@ internal static class GameWindowShutdownManifest
Hard("interaction/UI late bindings", () => frame.InteractionBindings?.Dispose()),
Hard("mouse capture", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement()),
Hard("retail UI", () => DisposeRetailUi(live.RetailUi)),
Hard("combat target", () => live.CombatTarget?.Dispose()),
Hard("combat attack", () => live.CombatAttack?.Dispose()),
Hard("magic runtime", () => live.Magic?.Dispose()),
Hard("item interaction", () => live.ItemInteraction?.Dispose()),
Hard("external containers", () => live.ExternalContainers?.Dispose()),
Hard("streamer", () => live.Streamer?.Dispose()),
@ -393,12 +392,12 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("runtime entity/object stream",
[
Hard(
"runtime character state",
live.Character.Dispose),
Hard(
"runtime action state",
live.Actions.Dispose),
Hard(
"runtime character state",
live.Character.Dispose),
Hard(
"runtime inventory state",
live.Inventory.Dispose),

View file

@ -1,4 +1,3 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
@ -43,8 +42,7 @@ internal sealed class CurrentGameRuntimeAdapter
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat)
GameplayInputFrameController gameplayInput)
{
_view = new CurrentGameRuntimeViewAdapter(
session,
@ -75,7 +73,6 @@ internal sealed class CurrentGameRuntimeAdapter
actions,
selection,
gameplayInput,
combat,
_events);
}
@ -95,6 +92,7 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeSessionCommands Session => _commands;
public IRuntimeSelectionCommands Selection => _commands;
public IRuntimeCombatCommands Combat => _commands;
public IRuntimeMagicCommands Magic => _commands;
public IRuntimeMovementCommands MovementCommands => _commands;
IRuntimeMovementCommands IGameRuntimeCommands.Movement => _commands;
public IRuntimeChatCommands ChatCommands => _commands;

View file

@ -1,4 +1,3 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
@ -19,6 +18,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
: IRuntimeSessionCommands,
IRuntimeSelectionCommands,
IRuntimeCombatCommands,
IRuntimeMagicCommands,
IRuntimeMovementCommands,
IRuntimeChatCommands,
IRuntimePortalCommands,
@ -36,7 +36,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly RuntimeActionState _actions;
private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput;
private readonly ILiveCombatModeCommand _combat;
private readonly ICurrentGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter(
@ -49,7 +48,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeActionState actions,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat,
ICurrentGameRuntimeEventSink events)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
@ -63,7 +61,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_gameplayInput = gameplayInput
?? throw new ArgumentNullException(nameof(gameplayInput));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
@ -178,8 +175,16 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus status;
if (command == RuntimeCombatCommand.ToggleMode)
{
_combat.Toggle();
status = RuntimeCommandStatus.Accepted;
RuntimeCombatModeRequestResult result =
_actions.CombatMode.Toggle();
status = result.Status switch
{
RuntimeCombatModeRequestStatus.Sent =>
RuntimeCommandStatus.Accepted,
RuntimeCombatModeRequestStatus.Inactive =>
RuntimeCommandStatus.Inactive,
_ => RuntimeCommandStatus.Rejected,
};
}
else
{
@ -190,6 +195,49 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(status);
}
public RuntimeCommandResult ExecuteAttack(
RuntimeGenerationToken expectedGeneration,
in RuntimeCombatAttackInput command)
{
RuntimeCommandStatus gate = Validate(
expectedGeneration,
requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
RuntimeCommandStatus status = _actions.CombatAttack.HandleCommand(
command)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Unsupported;
_events.EmitCommand(
RuntimeCommandDomain.Combat,
operation: 0x100 + (int)command.Command,
status);
return Result(status);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeMagicCommand command)
{
RuntimeCommandStatus gate = Validate(
expectedGeneration,
requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
CastRequestResult cast = _actions.SpellCast.Cast(command.SpellId);
RuntimeCommandStatus status = cast == CastRequestResult.Sent
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected;
_events.EmitCommand(
RuntimeCommandDomain.Magic,
operation: (int)cast,
status,
command.SpellId);
return Result(status, command.SpellId);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command)

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
@ -158,25 +159,29 @@ public sealed class MagicCatalog
}
/// <summary>
/// App-layer owner for the live retail magic request path. The server owns
/// turning, motion, fizzle, mana/component consumption, effects, and results.
/// App content/policy projection for Runtime's live retail magic request
/// owner. The server owns turning, motion, fizzle, mana/component consumption,
/// effects, and results.
/// </summary>
public sealed class MagicRuntime
public sealed class MagicRuntime : IDisposable
{
private readonly SpellComponentRequirementService _requirements;
private IDisposable? _castOperationsBinding;
private MagicRuntime(
MagicCatalog catalog,
SpellCastingController casting,
SpellComponentRequirementService requirements)
RuntimeSpellCastState casting,
SpellComponentRequirementService requirements,
IDisposable castOperationsBinding)
{
Catalog = catalog;
Casting = casting;
_requirements = requirements;
_castOperationsBinding = castOperationsBinding;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
public RuntimeSpellCastState Casting { get; }
public IReadOnlyList<SpellExamineComponent> GetExamineComponents(uint spellId)
{
@ -200,11 +205,11 @@ public sealed class MagicRuntime
return result;
}
public static MagicRuntime Create(
internal static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
RuntimeSpellCastState casting,
RuntimeSpellCastOperationsSlot operationsSlot,
ClientObjectTable objects,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Func<string> accountName,
Action stopCompletely,
@ -215,39 +220,29 @@ public sealed class MagicRuntime
Func<bool> canSend)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(casting);
ArgumentNullException.ThrowIfNull(operationsSlot);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(displayMessage);
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
objects, localPlayerId, accountName);
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
{
if (objects.Get(target) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
localPlayerId(), targetObject, spell);
if (showMessage && !result.Allowed && result.Message is { } message)
displayMessage(message);
return result.Allowed;
}
var casting = new SpellCastingController(
spellbook,
selectedObject,
var operations = new LiveSpellCastOperations(
requirements,
objects,
localPlayerId,
stopCompletely,
sendUntargeted,
sendTargeted,
displayMessage,
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
hasRequiredComponents: requirements.HasRequiredComponents,
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
return new MagicRuntime(catalog, casting, requirements);
incrementBusy,
canSend);
IDisposable binding = operationsSlot.BindOwned(operations);
return new MagicRuntime(catalog, casting, requirements, binding);
}
public void Reset() => Casting.Reset();
public void Dispose() =>
Interlocked.Exchange(ref _castOperationsBinding, null)?.Dispose();
}

View file

@ -0,0 +1,142 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Spells;
/// <summary>
/// Early Runtime construction seam for the App-owned live spell catalog,
/// object-table policy, movement, transport, message, and busy sinks.
/// Runtime owns cast intent; this slot owns no gameplay state.
/// </summary>
internal sealed class RuntimeSpellCastOperationsSlot
: IRuntimeSpellCastOperations
{
private IRuntimeSpellCastOperations? _owner;
public IDisposable BindOwned(IRuntimeSpellCastOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
throw new InvalidOperationException(
"Runtime spell-cast operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(IRuntimeSpellCastOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
public uint LocalPlayerId => _owner?.LocalPlayerId ?? 0u;
public bool CanSend => _owner?.CanSend == true;
public bool HasRequiredComponents(uint spellId) =>
_owner?.HasRequiredComponents(spellId) == true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) =>
_owner?.IsTargetCompatible(targetId, spell, showMessage) == true;
public void StopCompletely() => _owner?.StopCompletely();
public void SendUntargeted(uint spellId) =>
_owner?.SendUntargeted(spellId);
public void SendTargeted(uint targetId, uint spellId) =>
_owner?.SendTargeted(targetId, spellId);
public void DisplayMessage(string message) =>
_owner?.DisplayMessage(message);
public void IncrementBusy() => _owner?.IncrementBusy();
private sealed class Binding : IDisposable
{
private RuntimeSpellCastOperationsSlot? _slot;
private readonly IRuntimeSpellCastOperations _expected;
public Binding(
RuntimeSpellCastOperationsSlot slot,
IRuntimeSpellCastOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
internal sealed class LiveSpellCastOperations : IRuntimeSpellCastOperations
{
private readonly SpellComponentRequirementService _requirements;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public LiveSpellCastOperations(
SpellComponentRequirementService requirements,
ClientObjectTable objects,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Action incrementBusy,
Func<bool> canSend)
{
_requirements = requirements
?? throw new ArgumentNullException(nameof(requirements));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayerId = localPlayerId
?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely
?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted
?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted
?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage
?? throw new ArgumentNullException(nameof(displayMessage));
_incrementBusy = incrementBusy
?? throw new ArgumentNullException(nameof(incrementBusy));
_canSend = canSend ?? throw new ArgumentNullException(nameof(canSend));
}
public uint LocalPlayerId => _localPlayerId();
public bool CanSend => _canSend();
public bool HasRequiredComponents(uint spellId) =>
_requirements.HasRequiredComponents(spellId);
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage)
{
if (_objects.Get(targetId) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
LocalPlayerId,
targetObject,
spell);
if (showMessage && !result.Allowed && result.Message is { } message)
_displayMessage(message);
return result.Allowed;
}
public void StopCompletely() => _stopCompletely();
public void SendUntargeted(uint spellId) => _sendUntargeted(spellId);
public void SendTargeted(uint targetId, uint spellId) =>
_sendTargeted(targetId, spellId);
public void DisplayMessage(string message) => _displayMessage(message);
public void IncrementBusy() => _incrementBusy();
}

View file

@ -1,157 +0,0 @@
using System;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
/// <summary>
/// App-layer owner for retail cast intent. It validates the client-owned
/// selection rules, 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 SpellCastingController
{
private readonly Spellbook _spellbook;
private readonly Func<uint?> _selectedObject;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
private readonly Func<uint, bool> _hasRequiredComponents;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public SpellCastingController(
Spellbook spellbook,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Func<uint, SpellMetadata, bool>? targetCompatible = null,
Func<uint, bool>? hasRequiredComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null,
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
_targetCompatible = targetCompatible ?? ((_, _) => true);
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
_incrementBusy = incrementBusy ?? (() => { });
_canSend = canSend ?? (() => true);
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
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 _selectedObject() is uint target and not 0u
&& _targetCompatibleSilent(target, spell);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_displayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_hasRequiredComponents(spellId))
{
_displayMessage("You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _localPlayerId();
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selectedObject();
untargeted = false;
if (target is null or 0)
{
_displayMessage("You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_targetCompatible(target.Value, spell))
return CastRequestResult.IncompatibleTarget;
}
if (!_canSend())
{
_displayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_stopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_sendUntargeted(spellId);
else
_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.
_incrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
return CastRequestResult.Sent;
}
public void Reset()
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}

View file

@ -1,9 +1,9 @@
using AcDream.App.Rendering;
using AcDream.App.Combat;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
using DatReaderWriter;
using AcDream.Content;
@ -93,7 +93,7 @@ public static class FixtureProvider
case CombatUiController.LayoutId:
{
var combat = new CombatState();
var attacks = new CombatAttackController(
var attacks = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,

View file

@ -1,5 +1,5 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
@ -46,7 +46,7 @@ public sealed class CombatUiController : IRetainedPanelController
private readonly UiButton _autoTarget;
private readonly UiButton _keepInView;
private readonly CombatState _combat;
private readonly CombatAttackController _attacks;
private readonly RuntimeCombatAttackState _attacks;
private readonly Func<GameplaySettings> _gameplay;
private readonly Action<GameplaySettings> _setGameplay;
private readonly Action<bool> _setWindowVisible;
@ -64,7 +64,7 @@ public sealed class CombatUiController : IRetainedPanelController
UiButton autoTarget,
UiButton keepInView,
CombatState combat,
CombatAttackController attacks,
RuntimeCombatAttackState attacks,
Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels,
@ -125,7 +125,7 @@ public sealed class CombatUiController : IRetainedPanelController
public static CombatUiController? Bind(
ImportedLayout layout,
CombatState combat,
CombatAttackController attacks,
RuntimeCombatAttackState attacks,
Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels,

View file

@ -6,6 +6,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout;
@ -13,7 +14,7 @@ namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
/// request through <see cref="SpellCastingController"/>.
/// request through <see cref="RuntimeSpellCastState"/>.
/// </summary>
public sealed class SpellcastingUiController : IRetainedPanelController
{
@ -37,7 +38,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
];
private readonly Spellbook _spellbook;
private readonly SpellCastingController _casting;
private readonly RuntimeSpellCastState _casting;
private readonly SelectionState _selection;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
@ -69,7 +70,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private SpellcastingUiController(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
RuntimeSpellCastState casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
@ -158,7 +159,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
public static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
RuntimeSpellCastState casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,

View file

@ -12,6 +12,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using AcDream.Content;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
@ -44,13 +45,13 @@ public sealed record RadarRuntimeBindings(
public sealed record CombatRuntimeBindings(
CombatState State,
CombatAttackController Attacks,
RuntimeCombatAttackState Attacks,
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record MagicRuntimeBindings(
Spellbook Spellbook,
SpellCastingController Casting,
RuntimeSpellCastState Casting,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,

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

@ -1,10 +1,29 @@
using System.Diagnostics;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Combat;
namespace AcDream.Runtime.Gameplay;
internal interface ICombatAttackOperations
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();
@ -15,7 +34,8 @@ internal interface ICombatAttackOperations
bool AutoRepeatAttack { get; }
}
internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
internal sealed class DelegateRuntimeCombatAttackOperations
: IRuntimeCombatAttackOperations
{
private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
@ -25,7 +45,7 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
public DelegateCombatAttackOperations(
public DelegateRuntimeCombatAttackOperations(
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
Action? prepareAttackRequest,
@ -56,8 +76,8 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
/// <summary>
/// Owns retail's basic-combat attack request and power-bar state machine.
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
/// and <see cref="ReleaseAttack"/>; the wire send remains a host callback.
/// 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),
@ -68,7 +88,7 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0), and
/// <c>AbortAutomaticAttack</c> (0x0056AE90).
/// </remarks>
public sealed class CombatAttackController : IDisposable
public sealed class RuntimeCombatAttackState : IDisposable
{
public const double AttackPowerUpSeconds = CombatInputPlanner.AttackPowerUpSeconds;
public const double DualWieldPowerUpSeconds = CombatInputPlanner.DualWieldPowerUpSeconds;
@ -76,7 +96,7 @@ public sealed class CombatAttackController : IDisposable
public const float DesiredPowerStep = 1f / 6f;
private readonly CombatState _combat;
private readonly ICombatAttackOperations _operations;
private readonly IRuntimeCombatAttackOperations _operations;
private readonly Func<double> _now;
private bool _buildInProgress;
@ -91,7 +111,7 @@ public sealed class CombatAttackController : IDisposable
private float _latestPowerBarLevel;
private bool _disposed;
public CombatAttackController(
public RuntimeCombatAttackState(
CombatState combat,
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
@ -103,7 +123,7 @@ public sealed class CombatAttackController : IDisposable
Func<double>? now = null)
: this(
combat,
new DelegateCombatAttackOperations(
new DelegateRuntimeCombatAttackOperations(
canStartAttack,
sendAttack,
prepareAttackRequest,
@ -115,9 +135,9 @@ public sealed class CombatAttackController : IDisposable
{
}
internal CombatAttackController(
public RuntimeCombatAttackState(
CombatState combat,
ICombatAttackOperations operations,
IRuntimeCombatAttackOperations operations,
Func<double>? now = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
@ -134,6 +154,7 @@ public sealed class CombatAttackController : IDisposable
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
@ -142,38 +163,43 @@ public sealed class CombatAttackController : IDisposable
public event Action? StateChanged;
public bool HandleInputAction(InputAction action, ActivationType activation)
public bool HandleCommand(in RuntimeCombatAttackInput command)
{
AttackHeight? height = action switch
AttackHeight? height = command.Command switch
{
InputAction.CombatLowAttack => AttackHeight.Low,
InputAction.CombatMediumAttack => AttackHeight.Medium,
InputAction.CombatHighAttack => AttackHeight.High,
RuntimeCombatAttackCommand.LowAttack => AttackHeight.Low,
RuntimeCombatAttackCommand.MediumAttack => AttackHeight.Medium,
RuntimeCombatAttackCommand.HighAttack => AttackHeight.High,
_ => null,
};
if (height is not null)
{
if (activation == ActivationType.Press)
if (command.Activation == RuntimeInputActivation.Press)
PressAttack(height.Value);
else if (activation == ActivationType.Release)
else if (command.Activation == RuntimeInputActivation.Release)
ReleaseAttack();
return true;
}
if (activation != ActivationType.Press)
if (command.Activation != RuntimeInputActivation.Press)
return false;
if (action == InputAction.CombatDecreaseAttackPower)
if (command.Command == RuntimeCombatAttackCommand.DecreasePower)
{
StepDesiredPower(-1);
return true;
}
if (action == InputAction.CombatIncreaseAttackPower)
if (command.Command == RuntimeCombatAttackCommand.IncreasePower)
{
StepDesiredPower(1);
return true;
}
if (command.Command == RuntimeCombatAttackCommand.AbortForMovement)
{
AbortAutomaticAttack();
return true;
}
return false;
}
@ -183,20 +209,6 @@ public sealed class CombatAttackController : IDisposable
/// call <c>HandleNewForwardMovement</c>. Jump has its own equivalent cancel
/// in <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
/// </summary>
public void HandleMovementInput(InputAction action, ActivationType activation)
{
if (activation != ActivationType.Press)
return;
if (action is InputAction.MovementForward
or InputAction.MovementBackup
or InputAction.MovementRunLock
or InputAction.MovementJump)
{
AbortAutomaticAttack();
}
}
public void SetDesiredPower(float power)
{
float value = Math.Clamp(power, 0f, 1f);

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

@ -2,7 +2,13 @@ using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
namespace AcDream.App.Combat;
namespace AcDream.Runtime.Gameplay;
public interface IRuntimeCombatTargetOperations
{
bool AutoTarget { get; }
uint? SelectClosestTarget();
}
/// <summary>
/// Owns combat-target lifecycle around the shared selection state.
@ -14,25 +20,23 @@ namespace AcDream.App.Combat;
/// makes the selected combat object unavailable and enters that same notice
/// path.
/// </remarks>
public sealed class CombatTargetController : IDisposable
public sealed class RuntimeCombatTargetState : IDisposable
{
private readonly CombatState _combat;
private readonly SelectionState _selection;
private readonly Func<bool> _autoTarget;
private readonly Func<uint?> _selectClosestTarget;
private readonly IRuntimeCombatTargetOperations _operations;
private bool _disposed;
public bool IsDisposed => _disposed;
public CombatTargetController(
public RuntimeCombatTargetState(
CombatState combat,
SelectionState selection,
Func<bool> autoTarget,
Func<uint?> selectClosestTarget)
IRuntimeCombatTargetOperations operations)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_autoTarget = autoTarget ?? throw new ArgumentNullException(nameof(autoTarget));
_selectClosestTarget = selectClosestTarget
?? throw new ArgumentNullException(nameof(selectClosestTarget));
_operations = operations
?? throw new ArgumentNullException(nameof(operations));
_selection.Changed += OnSelectionChanged;
}
@ -64,10 +68,10 @@ public sealed class CombatTargetController : IDisposable
{
if (transition.SelectedObjectId is not null
|| transition.Reason == SelectionChangeReason.SessionReset
|| !_autoTarget()
|| !_operations.AutoTarget
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
return;
_selectClosestTarget();
_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,
}