diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index c2a9854c..27fe49e2 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -135,7 +135,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
-| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
+| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
@@ -208,7 +208,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
-| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
+| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
diff --git a/docs/research/2026-07-26-slice-j5-3-combat-magic-intent.md b/docs/research/2026-07-26-slice-j5-3-combat-magic-intent.md
new file mode 100644
index 00000000..1c4a97b1
--- /dev/null
+++ b/docs/research/2026-07-26-slice-j5-3-combat-magic-intent.md
@@ -0,0 +1,189 @@
+# Slice J5.3 — retail combat and magic intent ownership
+
+## Scope
+
+This note pins the retail ordering that must remain unchanged while the
+presentation-independent combat and casting state moves from `AcDream.App` to
+`AcDream.Runtime`. The move changes ownership and command types only. ACE
+remains authoritative for combat mode settlement, attacks, casts, damage,
+motion, component consumption, effects, and completion.
+
+Primary oracle:
+`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
+
+Cross-checks:
+
+- the ACE/ACViewer request-layout cross-checks pinned in
+ `docs/research/2026-07-11-retail-combat-bar-pseudocode.md` and
+ `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`;
+- the ACE corpse/death-state cross-check pinned in
+ `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`;
+- existing conformance ports in
+ `src/AcDream.Core/Combat/CombatModel.cs` and
+ `src/AcDream.Core/Spells/RetailSpellTargetPolicy.cs`
+
+## Named retail mechanisms
+
+### `ClientCombatSystem::Begin @ 0x0056A460`
+
+Pseudocode:
+
+```text
+combatMode = NonCombat
+pendingCombatMode = Undefined
+clear jump and power-bar timing
+clear request, server-pending, queued-request, attack, and repeat state
+requestedPower = 0
+requestedHeight = Medium
+desiredUiPower = 0.5
+trackingTarget = false
+advancedCombat = false
+```
+
+This is the session-reset state of the Runtime combat-intent owner.
+
+### `ClientCombatSystem::StartAttackRequest @ 0x0056C040`
+
+Pseudocode:
+
+```text
+target = GetAttackTarget()
+if advanced combat or (target exists and ObjectIsAttackable(target)):
+ attackRequestInProgress = true
+ requestedAttackPower = 1.0
+ FinishJump()
+ MaybeStopCompletely()
+ currentBuildIsAutomatic = false
+ AttemptStartBuildingAttack()
+else:
+ display the retail invalid-target notice
+```
+
+The request flags commit before the shared movement owner is asked to finish
+jump/stop. The host operation preserves that exact boundary and may publish
+the resulting movement packet before a later attack send.
+
+### `StartPowerBarBuild @ 0x0056ADB0`,
+`EndAttackRequest @ 0x0056C0E0`, and `UseTime @ 0x0056C1F0`
+
+Pseudocode:
+
+```text
+StartPowerBarBuild:
+ buildInProgress = true
+ buildStartTime = current game time
+ publish level 0
+ latestPowerBarLevel = 0
+
+EndAttackRequest:
+ if no request: return
+ requestInProgress = false
+ currentLevel = GetPowerBarLevel()
+ requestedPower = explicit power, advanced current level,
+ or min(desiredUiPower, currentLevel)
+ if server response pending:
+ remember one queued release
+ else if desired power reached or repeating:
+ ExecuteAttack()
+
+UseTime:
+ while building, publish the current level
+ if the player is not ready, cancel/reset according to repeat state
+ after release, execute once the captured requested level is reached
+```
+
+### `AbortAutomaticAttack @ 0x0056AE90`
+
+If a request, attack, server response, or repeat is active, retail sends
+`Event_CancelAttack`, clears repeat, and hides the combat power build.
+`ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0` invokes this before the
+new forward movement enters the interpreter. Runtime therefore receives a
+typed movement-abort command at that existing ordering boundary.
+
+### `HandleAttackDoneEvent @ 0x0056C500`
+
+Retail releases the attack busy reference (when held), clears server-pending
+state, aborts repeat on authoritative error, starts the next automatic build
+or attack only when repeat remains enabled, and finally drains the one queued
+release. Runtime must never predict completion.
+
+### `GetDefaultCombatMode @ 0x0056B310` and
+`ToggleCombatMode @ 0x0056C8C0`
+
+From non-combat, retail scans equipped contents in inventory-placement order:
+missile equipment selects Missile, a caster in Held selects Magic, ordinary
+melee/two-handed equipment selects Melee, and an incompatible Held item
+rejects the transition with its item-name notice. With no weapon it selects
+unarmed Melee. From any combat mode, Toggle selects NonCombat without running
+the default scan.
+
+The exact decision remains the existing Core port
+`CombatInputPlanner.GetDefaultCombatModeDecision`; Runtime owns the command
+state and App supplies ordered equipment plus transport/presentation sinks.
+
+### `RecvNotice_SelectionChanged @ 0x0056BD80` and
+`AutoTarget @ 0x0056BC80`
+
+When selection becomes zero in Melee/Missile mode and Auto Target is enabled,
+retail enters its next-target selection path. An authoritative Dead motion
+makes the current target unavailable and reaches the same selection-change
+path. Runtime owns the identity transition; App supplies the world query that
+selects the closest retail-compatible hostile.
+
+### `ClientMagicSystem::CastSpell @ 0x00568040`
+
+Pseudocode:
+
+```text
+resolve the exact known spell metadata
+if components are required:
+ resolve the appropriate formula
+ reject when a required component is unavailable
+resolve self, untargeted, or selected target
+validate the target against the spell target mask
+FreeHandsAndCastSpell(spellId, targetId)
+```
+
+### `ClientMagicSystem::FreeHandsAndCastSpell @ 0x00566EF0`
+
+Pseudocode:
+
+```text
+MaybeStopCompletely()
+if targetId == 0:
+ Event_CastUntargetedSpell(spellId)
+else:
+ Event_CastTargetedSpell(targetId, spellId)
+IncrementBusyCount()
+```
+
+The cast owner therefore stops first, emits exactly one packet, and increments
+the shared busy reference only after a successful send. It does not locally
+turn the avatar: retail sends after `MaybeStopCompletely`, and the
+authoritative server turn/cast motion supplies the visible facing. Send
+failure clears the remembered request and cannot increment busy.
+
+`ClientMagicSystem::OnEndCharacterSession @ 0x00566F50` clears selected and
+targeting spell state, which maps to Runtime session reset.
+
+## Ownership result
+
+Runtime owns:
+
+- attack build/request/repeat/queued-release state and time;
+- typed attack commands and movement-abort intent;
+- combat-mode command policy;
+- authoritative target-death/auto-target identity transitions;
+- cast validation intent and last requested spell/target;
+- reset, teardown, revisions, and presentation-independent snapshots.
+
+App retains:
+
+- input-key and retained-widget mapping;
+- ordered equipped-object and closest-hostile world queries;
+- local movement preparation and outbound transport;
+- target/spell compatibility data that depends on the live object table;
+- DAT spell catalog/formulas/components/icons;
+- messages, bars, animations, audio, particles, and camera presentation.
+
+No new retail divergence is introduced by this ownership move.
diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
index e84945fa..26e69ed6 100644
--- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
+++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
@@ -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.
///
-internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
+internal sealed class LiveCombatAttackOperations
+ : IRuntimeCombatAttackOperations
{
private readonly CombatState _combat;
private readonly ICombatAttackTargetSource _targets;
diff --git a/src/AcDream.App/Combat/LiveCombatModeCommandController.cs b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs
index 52cda267..b89c6720 100644
--- a/src/AcDream.App/Combat/LiveCombatModeCommandController.cs
+++ b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs
@@ -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
}
}
-///
-/// Owns the input-facing combat-mode command. The choice itself remains the
-/// retail port in :
-/// ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310 and
-/// ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0. ACE remains the
-/// authority; the local state update preserves the accepted immediate toolbar
-/// response while the server confirmation travels through the normal route.
-///
-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 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 GetOrderedEquipment() =>
+ _equipment.GetOrderedEquipment();
+ public void NotifyExplicitCombatModeRequest() =>
+ _itemIntent.NotifyExplicitCombatModeRequest();
+ public void SendChangeCombatMode(CombatMode mode) =>
+ _authority.SendChangeCombatMode(mode);
+}
+
+///
+/// App presentation adapter for Runtime's retail combat-mode decision.
+///
+internal sealed class RuntimeCombatModeCommandAdapter : ILiveCombatModeCommand
+{
+ private readonly RuntimeCombatModeState _owner;
private readonly Action _log;
private readonly Action? _toast;
private readonly Action _systemMessage;
- public LiveCombatModeCommandController(
- ILiveCombatModeAuthority authority,
- ICombatEquipmentSource equipment,
- CombatState combat,
- IExplicitCombatModeIntentSink itemIntent,
+ public RuntimeCombatModeCommandAdapter(
+ RuntimeCombatModeState owner,
Action? log = null,
Action? toast = null,
Action? 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);
}
diff --git a/src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs b/src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs
new file mode 100644
index 00000000..c3e0e75b
--- /dev/null
+++ b/src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs
@@ -0,0 +1,63 @@
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.App.Combat;
+
+///
+/// Early Runtime construction seam for the App-owned closest-hostile query.
+/// The slot owns no target identity or selection state.
+///
+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 autoTarget,
+ Func selectClosestTarget)
+ : IRuntimeCombatTargetOperations
+{
+ private readonly Func _autoTarget = autoTarget
+ ?? throw new ArgumentNullException(nameof(autoTarget));
+ private readonly Func _selectClosestTarget = selectClosestTarget
+ ?? throw new ArgumentNullException(nameof(selectClosestTarget));
+
+ public bool AutoTarget => _autoTarget();
+ public uint? SelectClosestTarget() => _selectClosestTarget();
+}
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index d151880b..822a1bf5 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -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 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 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();
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index 36f0026d..58ed4c30 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -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? 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",
diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs
index aad93671..f5b34fd0 100644
--- a/src/AcDream.App/Input/GameplayInputFrameController.cs
+++ b/src/AcDream.App/Input/GameplayInputFrameController.cs
@@ -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));
+ }
}
///
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index e8614c41..74587185 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -58,7 +58,7 @@ internal sealed record LiveSessionInteractionRuntime(
PlayerModeController PlayerMode,
PlayerModeAutoEntry PlayerModeAutoEntry,
ItemInteractionController ItemInteraction,
- CombatAttackController CombatAttack,
+ RuntimeCombatAttackState CombatAttack,
SelectionInteractionController SelectionInteractions);
internal sealed record LiveSessionWorldRuntime(
diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
index 9935c67c..78618fe4 100644
--- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -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,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 62eeb479..126bf3f9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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,
diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs
index 360663c8..1f83bfdb 100644
--- a/src/AcDream.App/Rendering/GameWindowLifetime.cs
+++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs
@@ -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),
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
index a52d4fea..8b71cfc3 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
@@ -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;
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
index 773acb69..910b58f0 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
@@ -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)
diff --git a/src/AcDream.App/Spells/MagicRuntime.cs b/src/AcDream.App/Spells/MagicRuntime.cs
index 8c6e0b43..3c644203 100644
--- a/src/AcDream.App/Spells/MagicRuntime.cs
+++ b/src/AcDream.App/Spells/MagicRuntime.cs
@@ -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
}
///
-/// 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.
///
-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 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 selectedObject,
Func localPlayerId,
Func accountName,
Action stopCompletely,
@@ -215,39 +220,29 @@ public sealed class MagicRuntime
Func 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();
}
diff --git a/src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs b/src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs
new file mode 100644
index 00000000..33ac58cd
--- /dev/null
+++ b/src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs
@@ -0,0 +1,142 @@
+using AcDream.Core.Items;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.App.Spells;
+
+///
+/// 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.
+///
+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 _localPlayerId;
+ private readonly Action _stopCompletely;
+ private readonly Action _sendUntargeted;
+ private readonly Action _sendTargeted;
+ private readonly Action _displayMessage;
+ private readonly Action _incrementBusy;
+ private readonly Func _canSend;
+
+ public LiveSpellCastOperations(
+ SpellComponentRequirementService requirements,
+ ClientObjectTable objects,
+ Func localPlayerId,
+ Action stopCompletely,
+ Action sendUntargeted,
+ Action sendTargeted,
+ Action displayMessage,
+ Action incrementBusy,
+ Func 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();
+}
diff --git a/src/AcDream.App/Spells/SpellCastingController.cs b/src/AcDream.App/Spells/SpellCastingController.cs
deleted file mode 100644
index 1eb6a759..00000000
--- a/src/AcDream.App/Spells/SpellCastingController.cs
+++ /dev/null
@@ -1,157 +0,0 @@
-using System;
-using AcDream.Core.Spells;
-
-namespace AcDream.App.Spells;
-
-///
-/// 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.
-///
-///
-/// Port of ClientMagicSystem::CastSpell (0x00568040) and
-/// FreeHandsAndCastSpell (0x00566EF0). This controller deliberately
-/// does not consume mana/components or start local effects.
-///
-public sealed class SpellCastingController
-{
- private readonly Spellbook _spellbook;
- private readonly Func _selectedObject;
- private readonly Func _localPlayerId;
- private readonly Action _stopCompletely;
- private readonly Action _sendUntargeted;
- private readonly Action _sendTargeted;
- private readonly Action _displayMessage;
- private readonly Func _targetCompatible;
- private readonly Func _targetCompatibleSilent;
- private readonly Func _hasRequiredComponents;
- private readonly Action _incrementBusy;
- private readonly Func _canSend;
-
- public SpellCastingController(
- Spellbook spellbook,
- Func selectedObject,
- Func localPlayerId,
- Action stopCompletely,
- Action sendUntargeted,
- Action sendTargeted,
- Action displayMessage,
- Func? targetCompatible = null,
- Func? hasRequiredComponents = null,
- Action? incrementBusy = null,
- Func? canSend = null,
- Func? 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,
-}
diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs
index 4491e1f9..eb375b40 100644
--- a/src/AcDream.App/Studio/FixtureProvider.cs
+++ b/src/AcDream.App/Studio/FixtureProvider.cs
@@ -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,
diff --git a/src/AcDream.App/UI/Layout/CombatUiController.cs b/src/AcDream.App/UI/Layout/CombatUiController.cs
index 41c84a0a..f2413e0d 100644
--- a/src/AcDream.App/UI/Layout/CombatUiController.cs
+++ b/src/AcDream.App/UI/Layout/CombatUiController.cs
@@ -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 _gameplay;
private readonly Action _setGameplay;
private readonly Action _setWindowVisible;
@@ -64,7 +64,7 @@ public sealed class CombatUiController : IRetainedPanelController
UiButton autoTarget,
UiButton keepInView,
CombatState combat,
- CombatAttackController attacks,
+ RuntimeCombatAttackState attacks,
Func gameplay,
Action 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 gameplay,
Action setGameplay,
CombatUiLabels labels,
diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
index b0c461e2..216338b7 100644
--- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
+++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
@@ -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;
///
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
-/// request through .
+/// request through .
///
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 _playerGuid;
@@ -69,7 +70,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private SpellcastingUiController(
ImportedLayout layout,
Spellbook spellbook,
- SpellCastingController casting,
+ RuntimeSpellCastState casting,
ClientObjectTable objects,
Func playerGuid,
Func 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 playerGuid,
Func resolveSpellIcon,
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index f857d80b..4d436089 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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 Gameplay,
Action SetGameplay);
public sealed record MagicRuntimeBindings(
Spellbook Spellbook,
- SpellCastingController Casting,
+ RuntimeSpellCastState Casting,
ClientObjectTable Objects,
Func PlayerGuid,
IReadOnlyDictionary Components,
diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs
index d0f271b3..f648986c 100644
--- a/src/AcDream.Runtime/GameRuntimeActionViews.cs
+++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs
@@ -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
{
diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs
index 99b68c46..2f7271a6 100644
--- a/src/AcDream.Runtime/GameRuntimeCommands.cs
+++ b/src/AcDream.Runtime/GameRuntimeCommands.cs
@@ -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; }
diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs
index d9cd8c1e..1cdd1919 100644
--- a/src/AcDream.Runtime/GameRuntimeEvents.cs
+++ b/src/AcDream.Runtime/GameRuntimeEvents.cs
@@ -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}")));
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
index f0f6197c..cae73205 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
@@ -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(
}
///
-/// 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.
///
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? 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? 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? 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)
{
diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs
similarity index 88%
rename from src/AcDream.App/Combat/CombatAttackController.cs
rename to src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs
index 34847e89..20bedded 100644
--- a/src/AcDream.App/Combat/CombatAttackController.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs
@@ -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 _canStartAttack;
private readonly Action _prepareAttackRequest;
@@ -25,7 +45,7 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
private readonly Func _playerReadyForAttack;
private readonly Func _autoRepeatAttack;
- public DelegateCombatAttackOperations(
+ public DelegateRuntimeCombatAttackOperations(
Func canStartAttack,
Func sendAttack,
Action? prepareAttackRequest,
@@ -56,8 +76,8 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
///
/// Owns retail's basic-combat attack request and power-bar state machine.
-/// UI buttons and keyboard actions both enter through
-/// and ; 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.
///
///
/// Retail references: ClientCombatSystem::Begin (0x0056A460),
@@ -68,7 +88,7 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
/// SetRequestedAttackHeight (0x0056C8F0), and
/// AbortAutomaticAttack (0x0056AE90).
///
-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 _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 canStartAttack,
Func sendAttack,
@@ -103,7 +123,7 @@ public sealed class CombatAttackController : IDisposable
Func? 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? 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;
/// The level retail publishes to the embedded combat meter.
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 HandleNewForwardMovement. Jump has its own equivalent cancel
/// in ClientCombatSystem::CommenceJump (0x0056AF90).
///
- 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);
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs
new file mode 100644
index 00000000..fbf7e4aa
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs
@@ -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 GetOrderedEquipment();
+ void NotifyExplicitCombatModeRequest();
+ void SendChangeCombatMode(CombatMode mode);
+}
+
+///
+/// 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.
+///
+///
+/// Port of ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310
+/// and ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0.
+///
+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);
+ }
+}
diff --git a/src/AcDream.App/Combat/CombatTargetController.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatTargetState.cs
similarity index 77%
rename from src/AcDream.App/Combat/CombatTargetController.cs
rename to src/AcDream.Runtime/Gameplay/RuntimeCombatTargetState.cs
index 4ce51f96..91fb41ed 100644
--- a/src/AcDream.App/Combat/CombatTargetController.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatTargetState.cs
@@ -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();
+}
///
/// 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.
///
-public sealed class CombatTargetController : IDisposable
+public sealed class RuntimeCombatTargetState : IDisposable
{
private readonly CombatState _combat;
private readonly SelectionState _selection;
- private readonly Func _autoTarget;
- private readonly Func _selectClosestTarget;
+ private readonly IRuntimeCombatTargetOperations _operations;
private bool _disposed;
+ public bool IsDisposed => _disposed;
- public CombatTargetController(
+ public RuntimeCombatTargetState(
CombatState combat,
SelectionState selection,
- Func autoTarget,
- Func 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();
}
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs
new file mode 100644
index 00000000..45450a7f
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs
@@ -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();
+}
+
+///
+/// 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.
+///
+///
+/// Port of ClientMagicSystem::CastSpell (0x00568040) and
+/// FreeHandsAndCastSpell (0x00566EF0). This controller deliberately
+/// does not consume mana/components or start local effects.
+///
+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,
+}
diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs
index fa2b9122..a35b9ff6 100644
--- a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs
+++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs
@@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
+using AcDream.Runtime.Gameplay;
using AcDream.Core.Net;
namespace AcDream.App.Tests.Combat;
@@ -152,7 +153,7 @@ public sealed class LiveCombatAttackOperationsTests
public void Show(string message) => Messages.Add(message);
}
- private sealed class FakeOperations : ICombatAttackOperations
+ private sealed class FakeOperations : IRuntimeCombatAttackOperations
{
public bool CanStartValue { get; init; } = true;
public bool CanStartAttack() => CanStartValue;
diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs
index 178bdd9a..5aa0fb56 100644
--- a/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs
+++ b/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs
@@ -1,6 +1,7 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
+using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Combat;
@@ -148,11 +149,13 @@ public sealed class LiveCombatModeCommandControllerTests
Authority = new FakeAuthority(Calls);
Equipment = new FakeEquipment();
Intent = new FakeIntent(Calls);
- Controller = new LiveCombatModeCommandController(
+ var operations = new LiveCombatModeOperations(
Authority,
Equipment,
- Combat,
- Intent,
+ Intent);
+ Runtime = new RuntimeCombatModeState(Combat, operations);
+ Controller = new RuntimeCombatModeCommandAdapter(
+ Runtime,
line => Calls.Add($"log:{line}"),
line => Calls.Add($"toast:{line}"),
line => Calls.Add($"system:{line}"));
@@ -163,7 +166,8 @@ public sealed class LiveCombatModeCommandControllerTests
public FakeEquipment Equipment { get; }
public CombatState Combat { get; } = new();
public FakeIntent Intent { get; }
- public LiveCombatModeCommandController Controller { get; }
+ public RuntimeCombatModeState Runtime { get; }
+ public RuntimeCombatModeCommandAdapter Controller { get; }
}
private sealed class FakeAuthority(List calls)
diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs
index 4366d3e3..2172b563 100644
--- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs
@@ -3,9 +3,14 @@ using AcDream.App.Combat;
using AcDream.App.Composition;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
+using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Composition;
@@ -17,7 +22,6 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint.InputCaptureBound,
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
- InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
InteractionRetainedUiCompositionPoint.MouseInputWired,
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
@@ -37,14 +41,15 @@ public sealed class InteractionRetainedUiCompositionTests
Assert.Equal(
[
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
- InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
+ InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
.. UiPoints,
InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points);
Assert.NotNull(result.RetainedUi);
+ Assert.NotNull(result.Magic);
Assert.Empty(fixture.Factory.Releases);
}
@@ -56,13 +61,14 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiResult result = fixture.Compose();
Assert.Null(result.RetainedUi);
+ Assert.NotNull(result.Magic);
Assert.Equal(
[
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
- InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
+ InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points);
@@ -108,14 +114,12 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint point)
{
var acquired = new List { "late bindings" };
- if (point >= InteractionRetainedUiCompositionPoint.CombatAttackCreated)
- acquired.Add("combat attack");
- if (point >= InteractionRetainedUiCompositionPoint.CombatTargetCreated)
- acquired.Add("combat target");
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
acquired.Add("external container");
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
acquired.Add("item interaction");
+ if (point >= InteractionRetainedUiCompositionPoint.MagicRuntimeCreated)
+ acquired.Add("magic runtime");
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
acquired.Add("retained UI lease");
acquired.Reverse();
@@ -132,10 +136,9 @@ public sealed class InteractionRetainedUiCompositionTests
Assert.Equal(
[
"retained UI lease",
+ "magic runtime",
"item interaction",
"external container",
- "combat target",
- "combat attack",
"late bindings",
], fixture.Factory.Releases);
}
@@ -172,6 +175,13 @@ public sealed class InteractionRetainedUiCompositionTests
Publication = new Publication(publicationFailure);
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
with { RetailUi = retailUi };
+ var actions = new RuntimeActionState(
+ new InventoryTransactionState(new ClientObjectTable()),
+ new Spellbook(),
+ new NoopCombatOperations(),
+ new NoopCombatTargetOperations(),
+ new NoopCombatModeOperations(),
+ new NoopSpellOperations());
Dependencies = new InteractionRetainedUiDependencies(
Options: options,
Gl: null!,
@@ -186,8 +196,10 @@ public sealed class InteractionRetainedUiCompositionTests
RetainedInputCapture: null!,
InputDispatcher: null,
Settings: null!,
- Actions: null!,
- CombatAttackOperations: null!,
+ Actions: actions,
+ CombatAttackOperations: new NoopCombatOperations(),
+ CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),
+ SpellCastOperations: new RuntimeSpellCastOperationsSlot(),
Inventory: null!,
MagicCatalog: null!,
Character: null!,
@@ -233,14 +245,10 @@ public sealed class InteractionRetainedUiCompositionTests
public List Releases { get; } = [];
public int RetainedUiCalls { get; private set; }
- public CombatAttackController CreateCombatAttack(
- InteractionRetainedUiDependencies dependencies) =>
- Resource("combat attack");
-
- public CombatTargetController CreateCombatTarget(
+ public IDisposable BindCombatTarget(
InteractionRetainedUiDependencies dependencies,
DeferredSelectionUiAuthority selection) =>
- Resource("combat target");
+ new NoopDisposable();
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies dependencies,
@@ -252,12 +260,19 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionUiLateBindings lateBindings) =>
Resource("item interaction");
+ public MagicRuntime CreateMagicRuntime(
+ InteractionRetainedUiDependencies dependencies,
+ InteractionUiLateBindings lateBindings,
+ ItemInteractionController itemInteraction) =>
+ Resource("magic runtime");
+
public RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
RetailUiRuntimeLease lease,
- CombatAttackController combatAttack,
+ RuntimeCombatAttackState combatAttack,
ItemInteractionController itemInteraction,
+ MagicRuntime magic,
Action checkpoint)
{
RetainedUiCalls++;
@@ -270,7 +285,6 @@ public sealed class InteractionRetainedUiCompositionTests
Stub(),
Stub(),
Stub(),
- Stub(),
null);
}
@@ -296,6 +310,55 @@ public sealed class InteractionRetainedUiCompositionTests
}
}
+ private sealed class NoopCombatOperations
+ : IRuntimeCombatAttackOperations
+ {
+ public bool CanStartAttack() => false;
+ public void PrepareAttackRequest() { }
+ public bool SendAttack(AttackHeight height, float power) => false;
+ public void SendCancelAttack() { }
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => false;
+ public bool AutoRepeatAttack => false;
+ }
+
+ private sealed class NoopDisposable : IDisposable
+ {
+ public void Dispose() { }
+ }
+
+ private sealed class NoopSpellOperations : IRuntimeSpellCastOperations
+ {
+ public uint LocalPlayerId => 0u;
+ public bool CanSend => false;
+ public bool HasRequiredComponents(uint spellId) => false;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => false;
+ public void StopCompletely() { }
+ public void SendUntargeted(uint spellId) { }
+ public void SendTargeted(uint targetId, uint spellId) { }
+ public void DisplayMessage(string message) { }
+ public void IncrementBusy() { }
+ }
+
+ private sealed class NoopCombatTargetOperations
+ : IRuntimeCombatTargetOperations
+ {
+ public bool AutoTarget => false;
+ public uint? SelectClosestTarget() => null;
+ }
+
+ private sealed class NoopCombatModeOperations
+ : IRuntimeCombatModeOperations
+ {
+ public bool IsInWorld => false;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(CombatMode mode) { }
+ }
+
private sealed class Publication(bool fail)
: IGameWindowInteractionRetainedUiPublication
{
diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
index 57f7fe8e..938cfe61 100644
--- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
+++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
@@ -240,6 +240,7 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!;
+ public IRuntimeMagicCommands Magic => null!;
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
index 6ca906dc..57788833 100644
--- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
@@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.Runtime;
+using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
@@ -184,6 +185,13 @@ public sealed class GameplayInputCommandControllerTests
RuntimeCommandStatus.Accepted,
expectedGeneration);
}
+
+ public RuntimeCommandResult ExecuteAttack(
+ RuntimeGenerationToken expectedGeneration,
+ in RuntimeCombatAttackInput command) =>
+ new(
+ RuntimeCommandStatus.Unsupported,
+ expectedGeneration);
}
private sealed class FakeRuntimeView : IGameRuntimeView
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index 835469a6..f6a32817 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -5,6 +5,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Runtime;
+using AcDream.App.Spells;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.App.Update;
@@ -16,6 +17,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Social;
+using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
@@ -97,7 +99,12 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.True(portal.Accepted);
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
Assert.True(harness.MovementInput.AutoRunActive);
- Assert.Equal(1, harness.Combat.ToggleCount);
+ Assert.Equal(
+ [AcDream.Core.Combat.CombatMode.Melee],
+ harness.CombatMode.Sent);
+ Assert.Equal(
+ AcDream.Core.Combat.CombatMode.Melee,
+ harness.Actions.Combat.CurrentMode);
Assert.Contains(
harness.Commands.Published,
static command => command is SendChatCmd
@@ -178,10 +185,89 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(active, stopped.RetiredGeneration);
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
- Assert.Equal(0, harness.Combat.ToggleCount);
+ Assert.Empty(harness.CombatMode.Sent);
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
}
+ [Fact]
+ public void GraphicalAndDirectCombatMagicCommands_ReachTheExactSameOwners()
+ {
+ using var graphical = new Harness();
+ using var direct = new Harness();
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ graphical.Runtime.Session.Start(graphical.Runtime.Generation).Status);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ direct.Runtime.Session.Start(direct.Runtime.Generation).Status);
+ var directTrace = new RuntimeTraceRecorder();
+ using IDisposable directTraceSubscription =
+ direct.Runtime.Subscribe(directTrace);
+
+ var graphicalAttack = new RecordingAttackOperations();
+ var directAttack = new RecordingAttackOperations();
+ var graphicalSpell = new RecordingSpellOperations();
+ var directSpell = new RecordingSpellOperations();
+ using IDisposable graphicalAttackBinding =
+ graphical.CombatAttackOperations.BindOwned(graphicalAttack);
+ using IDisposable directAttackBinding =
+ direct.CombatAttackOperations.BindOwned(directAttack);
+ using IDisposable graphicalSpellBinding =
+ graphical.SpellCastOperations.BindOwned(graphicalSpell);
+ using IDisposable directSpellBinding =
+ direct.SpellCastOperations.BindOwned(directSpell);
+
+ PrepareCombatAndMagic(graphical);
+ PrepareCombatAndMagic(direct);
+
+ var graphicalInput =
+ new CombatAttackInputFrameAdapter(graphical.Actions.CombatAttack);
+ Assert.True(graphicalInput.HandleInputAction(
+ InputAction.CombatLowAttack,
+ ActivationType.Press));
+ Assert.True(graphicalInput.HandleInputAction(
+ InputAction.CombatLowAttack,
+ ActivationType.Release));
+ Assert.Equal(
+ CastRequestResult.Sent,
+ graphical.Actions.SpellCast.Cast(1u));
+
+ RuntimeGenerationToken generation = direct.Runtime.Generation;
+ Assert.True(direct.Runtime.Combat.ExecuteAttack(
+ generation,
+ new RuntimeCombatAttackInput(
+ RuntimeCombatAttackCommand.LowAttack,
+ RuntimeInputActivation.Press)).Accepted);
+ Assert.True(direct.Runtime.Combat.ExecuteAttack(
+ generation,
+ new RuntimeCombatAttackInput(
+ RuntimeCombatAttackCommand.LowAttack,
+ RuntimeInputActivation.Release)).Accepted);
+ Assert.True(direct.Runtime.Magic.Execute(
+ generation,
+ new RuntimeMagicCommand(1u)).Accepted);
+
+ Assert.Equal(graphicalAttack.Trace, directAttack.Trace);
+ Assert.Equal(graphicalSpell.Trace, directSpell.Trace);
+ Assert.Equal(
+ graphical.Actions.View.Snapshot.CombatAttack,
+ direct.Actions.View.Snapshot.CombatAttack);
+ Assert.Equal(
+ graphical.Actions.View.Snapshot.Magic,
+ direct.Actions.View.Snapshot.Magic);
+ Assert.Equal(
+ [
+ RuntimeCommandDomain.Combat,
+ RuntimeCommandDomain.Combat,
+ RuntimeCommandDomain.Magic,
+ ],
+ directTrace.Entries
+ .Where(static entry => entry.Kind == RuntimeTraceKind.Command)
+ .Select(static entry =>
+ (RuntimeCommandDomain)(entry.Code >> 16))
+ .ToArray());
+ }
+
[Fact]
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
{
@@ -668,6 +754,7 @@ public sealed class CurrentGameRuntimeAdapterTests
private readonly ItemInteractionController _items;
private readonly LiveSessionController _session;
+ private readonly IDisposable _combatModeBinding;
public Harness()
{
@@ -681,14 +768,26 @@ public sealed class CurrentGameRuntimeAdapterTests
EntityObjects);
Objects = EntityObjects.Objects;
Communication = new RuntimeCommunicationState();
- Actions = new RuntimeActionState(InventoryState.Transactions);
+ CombatAttackOperations = new CombatAttackOperationsSlot();
+ CombatModeOperations = new RuntimeCombatModeOperationsSlot();
+ SpellCastOperations = new RuntimeSpellCastOperationsSlot();
+ Actions = new RuntimeActionState(
+ InventoryState.Transactions,
+ Character.Spellbook,
+ CombatAttackOperations,
+ new RuntimeCombatTargetOperationsSlot(),
+ CombatModeOperations,
+ SpellCastOperations,
+ now: static () => 0d);
+ CombatMode = new RecordingCombatModeOperations();
+ _combatModeBinding =
+ CombatModeOperations.BindOwned(CombatMode);
MovementInput = new DispatcherMovementInputSource();
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
MovementInput,
mouseLook: null,
new NoopCombatInput());
- Combat = new RecordingCombatCommand();
Clock = new UpdateFrameClock();
WorldReveal = new WorldRevealCoordinator(
static (_, _) => true,
@@ -737,8 +836,7 @@ public sealed class CurrentGameRuntimeAdapterTests
WorldReveal,
Clock,
selectionController,
- GameplayInput,
- Combat);
+ GameplayInput);
}
public RuntimeOptions Options { get; }
@@ -750,11 +848,14 @@ public sealed class CurrentGameRuntimeAdapterTests
public ClientObjectTable Objects { get; }
public RuntimeCommunicationState Communication { get; }
public ChatLog Chat => Communication.Chat;
+ public CombatAttackOperationsSlot CombatAttackOperations { get; }
+ public RuntimeCombatModeOperationsSlot CombatModeOperations { get; }
+ public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
- public RecordingCombatCommand Combat { get; }
+ public RecordingCombatModeOperations CombatMode { get; }
public UpdateFrameClock Clock { get; }
public WorldRevealCoordinator WorldReveal { get; }
public RecordingCommandRouting Commands { get; }
@@ -769,12 +870,72 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication.Dispose();
_session.Dispose();
_items.Dispose();
+ _combatModeBinding.Dispose();
Actions.Dispose();
InventoryState.Dispose();
Entities.Clear();
}
}
+ private static void PrepareCombatAndMagic(Harness harness)
+ {
+ harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
+ harness.Actions.CombatAttack.SetDesiredPower(0f);
+ const string csv =
+ "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]\n"
+ + "1,Runtime Test Spell,0x0,true,0x0";
+ harness.Character.InstallSpellMetadata(
+ SpellTable.LoadFromReader(new System.IO.StringReader(csv)));
+ harness.Character.Spellbook.OnSpellLearned(1u);
+ }
+
+ private sealed class RecordingAttackOperations
+ : IRuntimeCombatAttackOperations
+ {
+ public List Trace { get; } = [];
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => true;
+ public bool AutoRepeatAttack => false;
+
+ public bool CanStartAttack() => true;
+
+ public void PrepareAttackRequest() => Trace.Add("prepare");
+
+ public bool SendAttack(
+ AcDream.Core.Combat.AttackHeight height,
+ float power)
+ {
+ Trace.Add($"attack:{height}:{power}");
+ return true;
+ }
+
+ public void SendCancelAttack() => Trace.Add("cancel");
+ }
+
+ private sealed class RecordingSpellOperations
+ : IRuntimeSpellCastOperations
+ {
+ public uint LocalPlayerId => Harness.PlayerGuid;
+ public bool CanSend => true;
+ public List Trace { get; } = [];
+
+ public bool HasRequiredComponents(uint spellId) => true;
+
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => true;
+
+ public void StopCompletely() => Trace.Add("stop");
+ public void SendUntargeted(uint spellId) =>
+ Trace.Add($"untargeted:{spellId}");
+ public void SendTargeted(uint targetId, uint spellId) =>
+ Trace.Add($"targeted:{targetId}:{spellId}");
+ public void DisplayMessage(string message) =>
+ Trace.Add($"message:{message}");
+ public void IncrementBusy() => Trace.Add("busy");
+ }
+
private static LiveSessionHost CreateHost(
LiveSessionController controller,
RecordingCommandRouting commands,
@@ -1031,10 +1192,15 @@ public sealed class CurrentGameRuntimeAdapterTests
public void Dispose() => _active = false;
}
- internal sealed class RecordingCombatCommand : ILiveCombatModeCommand
+ internal sealed class RecordingCombatModeOperations
+ : IRuntimeCombatModeOperations
{
- public int ToggleCount { get; private set; }
- public void Toggle() => ToggleCount++;
+ public List Sent { get; } = [];
+ public bool IsInWorld => true;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(
+ AcDream.Core.Combat.CombatMode mode) => Sent.Add(mode);
}
private sealed class NoopCombatInput : ICombatInputFrameController
diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs
index fc1c7382..4b5c0722 100644
--- a/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs
@@ -19,7 +19,7 @@ public sealed class RuntimeActionOwnershipTests
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
- "_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);",
+ "_runtimeActions = new RuntimeActionState(",
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
@@ -61,6 +61,18 @@ public sealed class RuntimeActionOwnershipTests
Assert.Empty(Regex.Matches(
production,
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeInteractionTransactionState\s*\("));
+ Assert.Empty(Regex.Matches(
+ production,
+ @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatAttackState\s*\("));
+ Assert.Empty(Regex.Matches(
+ production,
+ @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatTargetState\s*\("));
+ Assert.Empty(Regex.Matches(
+ production,
+ @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatModeState\s*\("));
+ Assert.Empty(Regex.Matches(
+ production,
+ @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeSpellCastState\s*\("));
Assert.Equal(
1,
Regex.Matches(
@@ -104,6 +116,11 @@ public sealed class RuntimeActionOwnershipTests
Assert.Contains("d.Actions.Transactions,", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal);
+ Assert.Contains(
+ "_dependencies.Actions.CombatAttack",
+ ui,
+ StringComparison.Ordinal);
+ Assert.Contains("d.Actions.SpellCast", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal);
Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal);
Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal);
diff --git a/tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs b/tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs
deleted file mode 100644
index 6c55c73b..00000000
--- a/tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs
+++ /dev/null
@@ -1,119 +0,0 @@
-using AcDream.App.Spells;
-using AcDream.Core.Spells;
-
-namespace AcDream.App.Tests.Spells;
-
-public sealed class SpellCastingControllerTests
-{
- [Fact]
- public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
- {
- Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
- uint target = 0;
- var controller = MakeController(book, selected: 99, player: 42,
- sendTargeted: (id, _) => target = id);
-
- Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
- Assert.Equal(42u, target);
- }
-
- [Fact]
- public void Cast_TargetedWithoutSelection_DoesNotSend()
- {
- Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
- int sends = 0;
- string message = "";
- var controller = MakeController(book, selected: null, player: 42,
- sendTargeted: (_, _) => sends++, display: value => message = value);
-
- Assert.Equal(CastRequestResult.NoTarget, controller.Cast(1));
- Assert.Equal(0, sends);
- Assert.Contains("select", message, StringComparison.OrdinalIgnoreCase);
- }
-
- [Fact]
- public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
- {
- Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
- var order = new List();
- var controller = MakeController(book, selected: null, player: 42,
- stop: () => order.Add("stop"),
- sendUntargeted: _ => order.Add("send"),
- incrementBusy: () => order.Add("busy"));
-
- Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
- Assert.Equal(["stop", "send", "busy"], order);
- }
-
- [Fact]
- public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
- {
- Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
- int sends = 0, busy = 0;
- var controller = MakeController(book, selected: null, player: 42,
- sendUntargeted: _ => sends++, hasComponents: _ => false,
- incrementBusy: () => busy++);
-
- Assert.Equal(CastRequestResult.MissingComponents, controller.Cast(1));
- Assert.Equal(0, sends);
- Assert.Equal(0, busy);
- }
-
- [Fact]
- public void Cast_Disconnected_DoesNotIncrementBusy()
- {
- Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
- int busy = 0;
- var controller = MakeController(book, selected: null, player: 42,
- incrementBusy: () => busy++, canSend: () => false);
-
- Assert.Equal(CastRequestResult.Unavailable, controller.Cast(1));
- Assert.Equal(0, busy);
- }
-
- [Fact]
- public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
- {
- Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
- int increments = 0;
- var controller = MakeController(book, selected: null, player: 42,
- sendUntargeted: _ => throw new InvalidOperationException("wire"),
- incrementBusy: () => increments++);
-
- Assert.Throws(() => controller.Cast(1));
- Assert.Equal(0, increments);
- Assert.Null(controller.LastRequestedSpellId);
- }
-
- private static SpellCastingController MakeController(
- Spellbook book,
- uint? selected,
- uint player,
- Action? stop = null,
- Action? sendUntargeted = null,
- Action? sendTargeted = null,
- Action? display = null,
- Func? hasComponents = null,
- Action? incrementBusy = null,
- Func? canSend = null) => new(
- book,
- () => selected,
- () => player,
- stop ?? (() => { }),
- sendUntargeted ?? (_ => { }),
- sendTargeted ?? ((_, _) => { }),
- display ?? (_ => { }),
- hasRequiredComponents: hasComponents,
- incrementBusy: incrementBusy,
- canSend: canSend);
-
- private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
- {
- const string header = "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
- string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
- SpellTable table = SpellTable.LoadFromReader(new StringReader($"{header}\n{row}"));
- var book = new Spellbook(table);
- book.OnSpellLearned(1);
- return book;
- }
-}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
index a8434966..5334ea81 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
@@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
+using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout;
@@ -33,7 +34,7 @@ public sealed class CombatUiControllerTests
Assert.False(high.Selected);
Assert.True(medium.Selected);
Assert.False(low.Selected);
- Assert.Equal(CombatAttackController.InitialDesiredPower, power.ScalarPosition);
+ Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, power.ScalarPosition);
}
[Fact]
@@ -123,7 +124,7 @@ public sealed class CombatUiControllerTests
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
"High", "Medium", "Low");
- private static CombatAttackController CreateAttacks(
+ private static RuntimeCombatAttackState CreateAttacks(
CombatState combat,
Func now,
List<(AttackHeight Height, float Power)> sent)
diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
index c4b1c4b1..57b3bca3 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
@@ -184,7 +184,7 @@ public class SelectedObjectControllerTests
h.StackMap[Replacement] = 1u;
h.Selection.Select(Dead, SelectionChangeSource.World);
- // CombatTargetController is registered before the retained toolbar in
+ // RuntimeCombatTargetState is registered before the retained toolbar in
// production. Its clear handler selects a replacement reentrantly.
h.Selection.Changed += transition =>
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
index 1898d66d..69464a70 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
@@ -4,6 +4,7 @@ using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
+using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
@@ -330,14 +331,17 @@ public sealed class SpellcastingUiControllerTests
SelectionState? selection = null,
Action? examineSpell = null)
{
- var casting = new SpellCastingController(
- spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
+ SelectionState selectionState = selection ?? new SelectionState();
+ var casting = new RuntimeSpellCastState(
+ spellbook,
+ selectionState,
+ new NoopSpellCastOperations());
return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
item => item.ObjectId,
useItem,
- selection ?? new SelectionState(),
+ selectionState,
(tab, position, spellId) =>
{
spellbook.SetFavorite(tab, position, spellId);
@@ -349,6 +353,22 @@ public sealed class SpellcastingUiControllerTests
examineSpell);
}
+ private sealed class NoopSpellCastOperations : IRuntimeSpellCastOperations
+ {
+ public uint LocalPlayerId => 1u;
+ public bool CanSend => true;
+ public bool HasRequiredComponents(uint spellId) => true;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => true;
+ public void StopCompletely() { }
+ public void SendUntargeted(uint spellId) { }
+ public void SendTargeted(uint targetId, uint spellId) { }
+ public void DisplayMessage(string message) { }
+ public void IncrementBusy() { }
+ }
+
private static void ApplyAnchors(UiElement parent)
{
foreach (UiElement child in parent.Children)
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index f355a708..43638ea8 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -597,11 +597,11 @@ public sealed class UpdateFrameOrchestratorTests
field => field.FieldType == typeof(Silk.NET.Input.IMouse));
FieldInfo combatOperations = Assert.Single(
- typeof(AcDream.App.Combat.CombatAttackController).GetFields(
+ typeof(AcDream.Runtime.Gameplay.RuntimeCombatAttackState).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_operations");
Assert.Equal(
- typeof(AcDream.App.Combat.ICombatAttackOperations),
+ typeof(AcDream.Runtime.Gameplay.IRuntimeCombatAttackOperations),
combatOperations.FieldType);
Assert.DoesNotContain(
typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields(
diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
index 40c9ab56..d6695e08 100644
--- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
+++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
@@ -192,7 +192,8 @@ public sealed class GameRuntimeContractTests
Assert.Contains("social=9:10:11", entry.Text);
Assert.Contains(
"actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" +
- "00000000:00000000:00000000:0:0",
+ "00000000:00000000:00000000:0:0:0:0:00000000:00000000:" +
+ "False:False:00000000:0:00000000:00000000",
entry.Text);
}
@@ -208,6 +209,10 @@ public sealed class GameRuntimeContractTests
typeof(RuntimeMovementSkillState),
typeof(RuntimeActionState),
typeof(InteractionState),
+ typeof(RuntimeCombatAttackState),
+ typeof(RuntimeCombatTargetState),
+ typeof(RuntimeCombatModeState),
+ typeof(RuntimeSpellCastState),
];
foreach (Type owner in owners)
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs
index 62058fc5..b55e62ef 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs
@@ -11,7 +11,7 @@ public sealed class RuntimeActionStateTests
public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
{
using var inventory = NewInventoryTransactions();
- using var actions = new RuntimeActionState(inventory);
+ using var actions = RuntimeActionTestFactory.Create(inventory);
actions.Selection.Select(
0x50000001u,
@@ -41,7 +41,7 @@ public sealed class RuntimeActionStateTests
public void ResetSessionConvergesEveryChildAfterObserverFailures()
{
using var inventory = NewInventoryTransactions();
- var actions = new RuntimeActionState(inventory);
+ var actions = RuntimeActionTestFactory.Create(inventory);
actions.Selection.Select(
0x50000001u,
SelectionChangeSource.World);
@@ -73,7 +73,7 @@ public sealed class RuntimeActionStateTests
public void DisposeIsTerminalAndConvergedAfterObserverFailures()
{
using var inventory = NewInventoryTransactions();
- var actions = new RuntimeActionState(inventory);
+ var actions = RuntimeActionTestFactory.Create(inventory);
actions.Selection.Select(
0x50000001u,
SelectionChangeSource.World);
@@ -100,8 +100,8 @@ public sealed class RuntimeActionStateTests
{
using var firstInventory = NewInventoryTransactions();
using var secondInventory = NewInventoryTransactions();
- using var first = new RuntimeActionState(firstInventory);
- using var second = new RuntimeActionState(secondInventory);
+ using var first = RuntimeActionTestFactory.Create(firstInventory);
+ using var second = RuntimeActionTestFactory.Create(secondInventory);
first.Selection.Select(
0x50000001u,
@@ -131,7 +131,19 @@ public sealed class RuntimeActionStateTests
OutboundCount: 0,
HasPendingPickup: false,
PendingPickupToken: 0u,
- DispatchFailureCount: 0)),
+ DispatchFailureCount: 0),
+ new RuntimeCombatAttackSnapshot(
+ Revision: 0,
+ RequestedHeight: AttackHeight.Medium,
+ DesiredPower: 0.5f,
+ PowerBarLevel: 0f,
+ BuildInProgress: false,
+ RequestInProgress: false,
+ RequestedPower: 0f),
+ new RuntimeSpellCastSnapshot(
+ Revision: 0,
+ LastRequestedSpellId: 0u,
+ LastRequestedTargetId: 0u)),
second.View.Snapshot);
}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionTestFactory.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionTestFactory.cs
new file mode 100644
index 00000000..12c16d70
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionTestFactory.cs
@@ -0,0 +1,64 @@
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+internal static class RuntimeActionTestFactory
+{
+ public static RuntimeActionState Create(
+ InventoryTransactionState inventory,
+ Func? now = null) =>
+ new(
+ inventory,
+ new Spellbook(),
+ new CombatOperations(),
+ new CombatTargetOperations(),
+ new CombatModeOperations(),
+ new SpellOperations(),
+ now);
+
+ private sealed class CombatOperations : IRuntimeCombatAttackOperations
+ {
+ public bool CanStartAttack() => false;
+ public void PrepareAttackRequest() { }
+ public bool SendAttack(AttackHeight height, float power) => false;
+ public void SendCancelAttack() { }
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => false;
+ public bool AutoRepeatAttack => false;
+ }
+
+ private sealed class CombatTargetOperations
+ : IRuntimeCombatTargetOperations
+ {
+ public bool AutoTarget => false;
+ public uint? SelectClosestTarget() => null;
+ }
+
+ private sealed class CombatModeOperations
+ : IRuntimeCombatModeOperations
+ {
+ public bool IsInWorld => false;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(CombatMode mode) { }
+ }
+
+ private sealed class SpellOperations : IRuntimeSpellCastOperations
+ {
+ public uint LocalPlayerId => 0u;
+ public bool CanSend => false;
+ public bool HasRequiredComponents(uint spellId) => false;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => false;
+ public void StopCompletely() { }
+ public void SendUntargeted(uint spellId) { }
+ public void SendTargeted(uint targetId, uint spellId) { }
+ public void DisplayMessage(string message) { }
+ public void IncrementBusy() { }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs
similarity index 86%
rename from tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs
index 750a9827..2525ed2c 100644
--- a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs
@@ -1,10 +1,9 @@
-using AcDream.App.Combat;
using AcDream.Core.Combat;
-using AcDream.UI.Abstractions.Input;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.App.Tests.Combat;
+namespace AcDream.Runtime.Tests.Gameplay;
-public sealed class CombatAttackControllerTests
+public sealed class RuntimeCombatAttackStateTests
{
[Fact]
public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
@@ -76,11 +75,13 @@ public sealed class CombatAttackControllerTests
using var controller = Create(combat, () => now, sent);
combat.SetCombatMode(CombatMode.Melee);
- Assert.True(controller.HandleInputAction(
- InputAction.CombatLowAttack, ActivationType.Press));
+ Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
+ RuntimeCombatAttackCommand.LowAttack,
+ RuntimeInputActivation.Press)));
now = 1.5d;
- Assert.True(controller.HandleInputAction(
- InputAction.CombatLowAttack, ActivationType.Release));
+ Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
+ RuntimeCombatAttackCommand.LowAttack,
+ RuntimeInputActivation.Release)));
Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
}
@@ -108,7 +109,7 @@ public sealed class CombatAttackControllerTests
double now = 0d;
bool ready = false;
var combat = new CombatState();
- using var controller = new CombatAttackController(
+ using var controller = new RuntimeCombatAttackState(
combat,
() => true,
(_, _) => true,
@@ -135,7 +136,7 @@ public sealed class CombatAttackControllerTests
int cancels = 0;
var sent = new List<(AttackHeight Height, float Power)>();
var combat = new CombatState();
- using var controller = new CombatAttackController(
+ using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (height, power) =>
@@ -153,8 +154,9 @@ public sealed class CombatAttackControllerTests
controller.ReleaseAttack();
Assert.Single(sent);
- controller.HandleMovementInput(
- InputAction.MovementForward, ActivationType.Press);
+ controller.HandleCommand(new RuntimeCombatAttackInput(
+ RuntimeCombatAttackCommand.AbortForMovement,
+ RuntimeInputActivation.Press));
combat.OnAttackDone(1u, 0u);
Assert.Equal(1, cancels);
@@ -168,7 +170,7 @@ public sealed class CombatAttackControllerTests
{
int cancels = 0;
var combat = new CombatState();
- using var controller = new CombatAttackController(
+ using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,
@@ -184,8 +186,8 @@ public sealed class CombatAttackControllerTests
{
var events = new List();
var combat = new CombatState();
- CombatAttackController? controller = null;
- controller = new CombatAttackController(
+ RuntimeCombatAttackState? controller = null;
+ controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) =>
@@ -221,7 +223,7 @@ public sealed class CombatAttackControllerTests
double now = 1d;
int cancels = 0;
var combat = new CombatState();
- using var controller = new CombatAttackController(
+ using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,
@@ -239,11 +241,11 @@ public sealed class CombatAttackControllerTests
Assert.Equal(0f, controller.RequestedAttackPower);
Assert.Equal(0f, controller.PowerBarLevel);
Assert.Equal(AttackHeight.Medium, controller.RequestedHeight);
- Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower);
+ Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, controller.DesiredPower);
Assert.Equal(0, cancels);
}
- private static CombatAttackController Create(
+ private static RuntimeCombatAttackState Create(
CombatState combat,
Func now,
List<(AttackHeight Height, float Power)> sent,
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatModeStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatModeStateTests.cs
new file mode 100644
index 00000000..1a2d6908
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatModeStateTests.cs
@@ -0,0 +1,121 @@
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+public sealed class RuntimeCombatModeStateTests
+{
+ [Fact]
+ public void OutsideWorld_IsCompleteNoOp()
+ {
+ var combat = new CombatState();
+ var operations = new Operations { IsInWorld = false };
+ var state = new RuntimeCombatModeState(combat, operations);
+
+ RuntimeCombatModeRequestResult result = state.Toggle();
+
+ Assert.Equal(RuntimeCombatModeRequestStatus.Inactive, result.Status);
+ Assert.Empty(operations.Trace);
+ Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
+ }
+
+ [Fact]
+ public void PeaceWithBow_OrdersIntentSendThenLocalState()
+ {
+ var combat = new CombatState();
+ var operations = new Operations();
+ operations.Equipment.Add(new ClientObject
+ {
+ ObjectId = 1u,
+ CurrentlyEquippedLocation = EquipMask.MissileWeapon,
+ Type = ItemType.MissileWeapon,
+ CombatUse = 2,
+ });
+ combat.CombatModeChanged += mode =>
+ operations.Trace.Add($"state:{mode}");
+ var state = new RuntimeCombatModeState(combat, operations);
+
+ RuntimeCombatModeRequestResult result = state.Toggle();
+
+ Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
+ Assert.Equal(CombatMode.Missile, result.Mode);
+ Assert.Equal(
+ ["intent", "equipment", "send:Missile", "state:Missile"],
+ operations.Trace);
+ }
+
+ [Fact]
+ public void ActiveCombat_LeavesWithoutEquipmentQuery()
+ {
+ var combat = new CombatState();
+ combat.SetCombatMode(CombatMode.Magic);
+ var operations = new Operations();
+ var state = new RuntimeCombatModeState(combat, operations);
+
+ RuntimeCombatModeRequestResult result = state.Toggle();
+
+ Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
+ Assert.Equal(CombatMode.NonCombat, result.Mode);
+ Assert.DoesNotContain("equipment", operations.Trace);
+ }
+
+ [Fact]
+ public void IncompatibleHeldItem_RejectsAfterExplicitIntent()
+ {
+ var combat = new CombatState();
+ var operations = new Operations();
+ operations.Equipment.Add(new ClientObject
+ {
+ ObjectId = 1u,
+ Name = "Torch",
+ CurrentlyEquippedLocation = EquipMask.Held,
+ Type = ItemType.Misc,
+ });
+ var state = new RuntimeCombatModeState(combat, operations);
+
+ RuntimeCombatModeRequestResult result = state.Toggle();
+
+ Assert.Equal(RuntimeCombatModeRequestStatus.Rejected, result.Status);
+ Assert.Equal(
+ "You can't enter combat mode while wielding the Torch",
+ result.Notice);
+ Assert.Equal(["intent", "equipment"], operations.Trace);
+ }
+
+ [Fact]
+ public void TransportFailure_DoesNotPublishLocalMode()
+ {
+ var combat = new CombatState();
+ var operations = new Operations { ThrowOnSend = true };
+ var state = new RuntimeCombatModeState(combat, operations);
+
+ Assert.Throws(() => state.Toggle());
+
+ Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
+ Assert.Equal(["intent", "equipment", "send:Melee"], operations.Trace);
+ }
+
+ private sealed class Operations : IRuntimeCombatModeOperations
+ {
+ public bool IsInWorld { get; init; } = true;
+ public bool ThrowOnSend { get; init; }
+ public List Equipment { get; } = [];
+ public List Trace { get; } = [];
+
+ public IReadOnlyList GetOrderedEquipment()
+ {
+ Trace.Add("equipment");
+ return Equipment;
+ }
+
+ public void NotifyExplicitCombatModeRequest() => Trace.Add("intent");
+
+ public void SendChangeCombatMode(CombatMode mode)
+ {
+ Trace.Add($"send:{mode}");
+ if (ThrowOnSend)
+ throw new InvalidOperationException("transport");
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatTargetStateTests.cs
similarity index 66%
rename from tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatTargetStateTests.cs
index 7a92e3e6..f3530e7a 100644
--- a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatTargetStateTests.cs
@@ -1,11 +1,11 @@
-using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.App.Tests.Combat;
+namespace AcDream.Runtime.Tests.Gameplay;
-public sealed class CombatTargetControllerTests
+public sealed class RuntimeCombatTargetStateTests
{
[Fact]
public void DeadSelectedTarget_AutoTargetEnabled_SelectsClosestReplacement()
@@ -15,16 +15,15 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0;
- using var controller = new CombatTargetController(
+ using var controller = new RuntimeCombatTargetState(
combat,
selection,
- autoTarget: () => true,
- selectClosestTarget: () =>
+ new Operations(true, () =>
{
calls++;
selection.Select(0x50000002u, SelectionChangeSource.System);
return 0x50000002u;
- });
+ }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@@ -40,8 +39,10 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0;
- using var controller = new CombatTargetController(
- combat, selection, () => false, () => { calls++; return null; });
+ using var controller = new RuntimeCombatTargetState(
+ combat,
+ selection,
+ new Operations(false, () => { calls++; return null; }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@@ -56,8 +57,10 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0;
- using var controller = new CombatTargetController(
- combat, selection, () => true, () => { calls++; return null; });
+ using var controller = new RuntimeCombatTargetState(
+ combat,
+ selection,
+ new Operations(true, () => { calls++; return null; }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@@ -72,8 +75,12 @@ public sealed class CombatTargetControllerTests
combat.SetCombatMode(CombatMode.Missile);
var selection = new SelectionState();
selection.Select(0x50000002u, SelectionChangeSource.World);
- using var controller = new CombatTargetController(
- combat, selection, () => true, () => throw new InvalidOperationException());
+ using var controller = new RuntimeCombatTargetState(
+ combat,
+ selection,
+ new Operations(
+ true,
+ () => throw new InvalidOperationException()));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@@ -88,12 +95,23 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState();
selection.Select(0x50000002u, SelectionChangeSource.World);
int calls = 0;
- using var controller = new CombatTargetController(
- combat, selection, () => true, () => { calls++; return null; });
+ using var controller = new RuntimeCombatTargetState(
+ combat,
+ selection,
+ new Operations(true, () => { calls++; return null; }));
selection.Reset();
Assert.Equal(0, calls);
Assert.Null(selection.SelectedObjectId);
}
+
+ private sealed class Operations(
+ bool autoTarget,
+ Func selectClosestTarget)
+ : IRuntimeCombatTargetOperations
+ {
+ public bool AutoTarget { get; } = autoTarget;
+ public uint? SelectClosestTarget() => selectClosestTarget();
+ }
}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs
index 21dc0cbb..e88f03aa 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs
@@ -15,7 +15,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState();
- var actions = new RuntimeActionState(inventory.Transactions);
+ var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
@@ -114,7 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState();
- var actions = new RuntimeActionState(inventory.Transactions);
+ var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeSpellCastStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeSpellCastStateTests.cs
new file mode 100644
index 00000000..fbe81740
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeSpellCastStateTests.cs
@@ -0,0 +1,173 @@
+using AcDream.Core.Selection;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+public sealed class RuntimeSpellCastStateTests
+{
+ [Fact]
+ public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
+ {
+ Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
+ uint target = 0;
+ var operations = new FakeOperations
+ {
+ LocalPlayerId = 42u,
+ SendTargetedAction = (id, _) => target = id,
+ };
+ RuntimeSpellCastState state = Create(book, operations, selected: 99u);
+
+ Assert.Equal(CastRequestResult.Sent, state.Cast(1));
+ Assert.Equal(42u, target);
+ }
+
+ [Fact]
+ public void Cast_TargetedWithoutSelection_DoesNotSend()
+ {
+ Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
+ var operations = new FakeOperations { LocalPlayerId = 42u };
+ RuntimeSpellCastState state = Create(book, operations);
+
+ Assert.Equal(CastRequestResult.NoTarget, state.Cast(1));
+ Assert.Equal(0, operations.TargetedSends);
+ Assert.Contains(
+ "select",
+ operations.LastMessage,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
+ {
+ Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
+ var order = new List();
+ var operations = new FakeOperations
+ {
+ LocalPlayerId = 42u,
+ StopAction = () => order.Add("stop"),
+ SendUntargetedAction = _ => order.Add("send"),
+ IncrementBusyAction = () => order.Add("busy"),
+ };
+ RuntimeSpellCastState state = Create(book, operations);
+
+ Assert.Equal(CastRequestResult.Sent, state.Cast(1));
+ Assert.Equal(["stop", "send", "busy"], order);
+ }
+
+ [Fact]
+ public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
+ {
+ Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
+ var operations = new FakeOperations
+ {
+ LocalPlayerId = 42u,
+ HasComponents = false,
+ };
+ RuntimeSpellCastState state = Create(book, operations);
+
+ Assert.Equal(CastRequestResult.MissingComponents, state.Cast(1));
+ Assert.Equal(0, operations.UntargetedSends);
+ Assert.Equal(0, operations.BusyIncrements);
+ }
+
+ [Fact]
+ public void Cast_Disconnected_DoesNotIncrementBusy()
+ {
+ Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
+ var operations = new FakeOperations
+ {
+ LocalPlayerId = 42u,
+ CanSend = false,
+ };
+ RuntimeSpellCastState state = Create(book, operations);
+
+ Assert.Equal(CastRequestResult.Unavailable, state.Cast(1));
+ Assert.Equal(0, operations.BusyIncrements);
+ }
+
+ [Fact]
+ public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
+ {
+ Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
+ var operations = new FakeOperations
+ {
+ LocalPlayerId = 42u,
+ SendUntargetedAction =
+ _ => throw new InvalidOperationException("wire"),
+ };
+ RuntimeSpellCastState state = Create(book, operations);
+
+ Assert.Throws(() => state.Cast(1));
+ Assert.Equal(0, operations.BusyIncrements);
+ Assert.Null(state.LastRequestedSpellId);
+ }
+
+ private static RuntimeSpellCastState Create(
+ Spellbook book,
+ FakeOperations operations,
+ uint? selected = null)
+ {
+ var selection = new SelectionState();
+ if (selected is uint id)
+ selection.Select(id, SelectionChangeSource.System);
+ return new RuntimeSpellCastState(book, selection, operations);
+ }
+
+ private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
+ {
+ const string header =
+ "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
+ string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
+ SpellTable table = SpellTable.LoadFromReader(
+ new StringReader($"{header}\n{row}"));
+ var book = new Spellbook(table);
+ book.OnSpellLearned(1);
+ return book;
+ }
+
+ private sealed class FakeOperations : IRuntimeSpellCastOperations
+ {
+ public uint LocalPlayerId { get; init; }
+ public bool CanSend { get; init; } = true;
+ public bool HasComponents { get; init; } = true;
+ public bool TargetCompatible { get; init; } = true;
+ public int UntargetedSends { get; private set; }
+ public int TargetedSends { get; private set; }
+ public int BusyIncrements { get; private set; }
+ public string LastMessage { get; private set; } = string.Empty;
+ public Action? StopAction { get; init; }
+ public Action? SendUntargetedAction { get; init; }
+ public Action? SendTargetedAction { get; init; }
+ public Action? IncrementBusyAction { get; init; }
+
+ public bool HasRequiredComponents(uint spellId) => HasComponents;
+
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => TargetCompatible;
+
+ public void StopCompletely() => StopAction?.Invoke();
+
+ public void SendUntargeted(uint spellId)
+ {
+ UntargetedSends++;
+ SendUntargetedAction?.Invoke(spellId);
+ }
+
+ public void SendTargeted(uint targetId, uint spellId)
+ {
+ TargetedSends++;
+ SendTargetedAction?.Invoke(targetId, spellId);
+ }
+
+ public void DisplayMessage(string message) => LastMessage = message;
+
+ public void IncrementBusy()
+ {
+ BusyIncrements++;
+ IncrementBusyAction?.Invoke();
+ }
+ }
+}