From 20df9d155db50706a42420d60a9b15861cf4bfe7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 11:56:40 +0200 Subject: [PATCH] refactor(runtime): own combat and magic intent Move attack build/repeat state, combat-mode policy, authoritative auto-target transitions, and spell-cast intent beneath RuntimeActionState. Keep App as the input, world-query, DAT-policy, transport, and presentation adapter while preserving retail request and busy ordering. Add direct/graphical parity, reset, failure, and instance-isolation coverage. Co-authored-by: Codex --- .../retail-divergence-register.md | 4 +- ...26-07-26-slice-j5-3-combat-magic-intent.md | 189 ++++++++++++++++++ .../Combat/LiveCombatAttackOperations.cs | 19 +- .../Combat/LiveCombatModeCommandController.cs | 143 +++++++------ .../RuntimeCombatTargetOperationsSlot.cs | 63 ++++++ .../InteractionRetainedUiComposition.cs | 120 +++++------ .../Composition/SessionPlayerComposition.cs | 18 +- .../Input/GameplayInputFrameController.cs | 50 ++++- .../Net/LiveSessionRuntimeFactory.cs | 2 +- .../LiveEntityNetworkUpdateController.cs | 5 +- src/AcDream.App/Rendering/GameWindow.cs | 27 ++- .../Rendering/GameWindowLifetime.cs | 13 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 6 +- .../CurrentGameRuntimeCommandAdapter.cs | 60 +++++- src/AcDream.App/Spells/MagicRuntime.cs | 57 +++--- .../Spells/RuntimeSpellCastOperationsSlot.cs | 142 +++++++++++++ .../Spells/SpellCastingController.cs | 157 --------------- src/AcDream.App/Studio/FixtureProvider.cs | 4 +- .../UI/Layout/CombatUiController.cs | 8 +- .../UI/Layout/SpellcastingUiController.cs | 9 +- src/AcDream.App/UI/RetailUiRuntime.cs | 5 +- src/AcDream.Runtime/GameRuntimeActionViews.cs | 18 +- src/AcDream.Runtime/GameRuntimeCommands.cs | 15 ++ src/AcDream.Runtime/GameRuntimeEvents.cs | 33 ++- .../Gameplay/RuntimeActionState.cs | 82 +++++++- .../Gameplay/RuntimeCombatAttackState.cs} | 86 ++++---- .../Gameplay/RuntimeCombatModeState.cs | 94 +++++++++ .../Gameplay/RuntimeCombatTargetState.cs} | 28 +-- .../Gameplay/RuntimeSpellCastState.cs | 161 +++++++++++++++ .../Combat/LiveCombatAttackOperationsTests.cs | 3 +- .../LiveCombatModeCommandControllerTests.cs | 12 +- .../InteractionRetainedUiCompositionTests.cs | 101 ++++++++-- .../InteractionUiRuntimeSourcesTests.cs | 1 + .../GameplayInputCommandControllerTests.cs | 8 + .../Runtime/CurrentGameRuntimeAdapterTests.cs | 186 ++++++++++++++++- .../Runtime/RuntimeActionOwnershipTests.cs | 19 +- .../Spells/SpellCastingControllerTests.cs | 119 ----------- .../UI/Layout/CombatUiControllerTests.cs | 5 +- .../Layout/SelectedObjectControllerTests.cs | 2 +- .../Layout/SpellcastingUiControllerTests.cs | 26 ++- .../World/UpdateFrameOrchestratorTests.cs | 4 +- .../GameRuntimeContractTests.cs | 7 +- .../Gameplay/RuntimeActionStateTests.cs | 24 ++- .../Gameplay/RuntimeActionTestFactory.cs | 64 ++++++ .../RuntimeCombatAttackStateTests.cs} | 38 ++-- .../Gameplay/RuntimeCombatModeStateTests.cs | 121 +++++++++++ .../RuntimeCombatTargetStateTests.cs} | 48 +++-- .../Gameplay/RuntimeGameplayOwnershipTests.cs | 4 +- .../Gameplay/RuntimeSpellCastStateTests.cs | 173 ++++++++++++++++ 49 files changed, 1949 insertions(+), 634 deletions(-) create mode 100644 docs/research/2026-07-26-slice-j5-3-combat-magic-intent.md create mode 100644 src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs create mode 100644 src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs delete mode 100644 src/AcDream.App/Spells/SpellCastingController.cs rename src/{AcDream.App/Combat/CombatAttackController.cs => AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs} (88%) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs rename src/{AcDream.App/Combat/CombatTargetController.cs => AcDream.Runtime/Gameplay/RuntimeCombatTargetState.cs} (77%) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs delete mode 100644 tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionTestFactory.cs rename tests/{AcDream.App.Tests/Combat/CombatAttackControllerTests.cs => AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs} (86%) create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatModeStateTests.cs rename tests/{AcDream.App.Tests/Combat/CombatTargetControllerTests.cs => AcDream.Runtime.Tests/Gameplay/RuntimeCombatTargetStateTests.cs} (66%) create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeSpellCastStateTests.cs 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(); + } + } +}