refactor(runtime): own combat and magic intent

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

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

View file

@ -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-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-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-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-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-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 | | 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-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-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-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-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` | | ~~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` |

View file

@ -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.

View file

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

View file

@ -3,6 +3,7 @@ using AcDream.App.Net;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
namespace AcDream.App.Combat; namespace AcDream.App.Combat;
@ -164,37 +165,96 @@ internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
} }
} }
/// <summary> internal sealed class RuntimeCombatModeOperationsSlot
/// Owns the input-facing combat-mode command. The choice itself remains the : IRuntimeCombatModeOperations
/// retail port in <see cref="CombatInputPlanner"/>: {
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and private IRuntimeCombatModeOperations? _owner;
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
/// authority; the local state update preserves the accepted immediate toolbar public IDisposable BindOwned(IRuntimeCombatModeOperations owner)
/// response while the server confirmation travels through the normal route. {
/// </summary> ArgumentNullException.ThrowIfNull(owner);
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand if (_owner is not null)
throw new InvalidOperationException(
"Runtime combat-mode operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(IRuntimeCombatModeOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
public bool IsInWorld => _owner?.IsInWorld == true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_owner?.GetOrderedEquipment() ?? [];
public void NotifyExplicitCombatModeRequest() =>
_owner?.NotifyExplicitCombatModeRequest();
public void SendChangeCombatMode(CombatMode mode) =>
_owner?.SendChangeCombatMode(mode);
private sealed class Binding : IDisposable
{
private RuntimeCombatModeOperationsSlot? _slot;
private readonly IRuntimeCombatModeOperations _expected;
public Binding(
RuntimeCombatModeOperationsSlot slot,
IRuntimeCombatModeOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
internal sealed class LiveCombatModeOperations
: IRuntimeCombatModeOperations
{ {
private readonly ILiveCombatModeAuthority _authority; private readonly ILiveCombatModeAuthority _authority;
private readonly ICombatEquipmentSource _equipment; private readonly ICombatEquipmentSource _equipment;
private readonly CombatState _combat;
private readonly IExplicitCombatModeIntentSink _itemIntent; private readonly IExplicitCombatModeIntentSink _itemIntent;
public LiveCombatModeOperations(
ILiveCombatModeAuthority authority,
ICombatEquipmentSource equipment,
IExplicitCombatModeIntentSink itemIntent)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
}
public bool IsInWorld => _authority.IsInWorld;
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_equipment.GetOrderedEquipment();
public void NotifyExplicitCombatModeRequest() =>
_itemIntent.NotifyExplicitCombatModeRequest();
public void SendChangeCombatMode(CombatMode mode) =>
_authority.SendChangeCombatMode(mode);
}
/// <summary>
/// App presentation adapter for Runtime's retail combat-mode decision.
/// </summary>
internal sealed class RuntimeCombatModeCommandAdapter : ILiveCombatModeCommand
{
private readonly RuntimeCombatModeState _owner;
private readonly Action<string> _log; private readonly Action<string> _log;
private readonly Action<string>? _toast; private readonly Action<string>? _toast;
private readonly Action<string> _systemMessage; private readonly Action<string> _systemMessage;
public LiveCombatModeCommandController( public RuntimeCombatModeCommandAdapter(
ILiveCombatModeAuthority authority, RuntimeCombatModeState owner,
ICombatEquipmentSource equipment,
CombatState combat,
IExplicitCombatModeIntentSink itemIntent,
Action<string>? log = null, Action<string>? log = null,
Action<string>? toast = null, Action<string>? toast = null,
Action<string>? systemMessage = null) Action<string>? systemMessage = null)
{ {
_authority = authority ?? throw new ArgumentNullException(nameof(authority)); _owner = owner ?? throw new ArgumentNullException(nameof(owner));
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
_log = log ?? (_ => { }); _log = log ?? (_ => { });
_toast = toast; _toast = toast;
_systemMessage = systemMessage ?? (_ => { }); _systemMessage = systemMessage ?? (_ => { });
@ -202,48 +262,19 @@ internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
public void Toggle() public void Toggle()
{ {
if (!_authority.IsInWorld) RuntimeCombatModeRequestResult result = _owner.Toggle();
if (result.Status == RuntimeCombatModeRequestStatus.Inactive)
return; return;
// Every explicit user combat request supersedes an in-flight auto-wield if (result.Status == RuntimeCombatModeRequestStatus.Rejected)
// settlement, including a request retail rejects before SetCombatMode.
_itemIntent.NotifyExplicitCombatModeRequest();
CombatMode currentMode = _combat.CurrentMode;
CombatMode nextMode;
if (currentMode != CombatMode.NonCombat)
{ {
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and string notice = result.Notice ?? string.Empty;
// never queries default equipment on this branch. _log($"combat: {notice}");
nextMode = CombatMode.NonCombat; _systemMessage(notice);
} return;
else
{
DefaultCombatModeDecision defaultDecision =
CombatInputPlanner.GetDefaultCombatModeDecision(
_equipment.GetOrderedEquipment());
nextMode = CombatInputPlanner.ToggleMode(
currentMode,
defaultDecision.Mode);
// GetDefaultCombatMode prints this notice before SetCombatMode.
// SetCombatMode then sees NonCombat == NonCombat and does no work.
if (defaultDecision.IncompatibleHeldItem is { } held)
{
string notice =
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
_log($"combat: {notice}");
_systemMessage(notice);
return;
}
} }
// Preserve the accepted command order: explicit intent was published string message = $"Combat mode {result.Mode}";
// above, then send the request and publish local presentation.
_authority.SendChangeCombatMode(nextMode);
_combat.SetCombatMode(nextMode);
string message = $"Combat mode {nextMode}";
_log($"combat: {message}"); _log($"combat: {message}");
_toast?.Invoke(message); _toast?.Invoke(message);
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
using AcDream.App.Combat;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.App.Net; using AcDream.App.Net;
@ -19,6 +18,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
: IRuntimeSessionCommands, : IRuntimeSessionCommands,
IRuntimeSelectionCommands, IRuntimeSelectionCommands,
IRuntimeCombatCommands, IRuntimeCombatCommands,
IRuntimeMagicCommands,
IRuntimeMovementCommands, IRuntimeMovementCommands,
IRuntimeChatCommands, IRuntimeChatCommands,
IRuntimePortalCommands, IRuntimePortalCommands,
@ -36,7 +36,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly RuntimeActionState _actions; private readonly RuntimeActionState _actions;
private readonly SelectionInteractionController _selection; private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput; private readonly GameplayInputFrameController _gameplayInput;
private readonly ILiveCombatModeCommand _combat;
private readonly ICurrentGameRuntimeEventSink _events; private readonly ICurrentGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter( public CurrentGameRuntimeCommandAdapter(
@ -49,7 +48,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeActionState actions, RuntimeActionState actions,
SelectionInteractionController selection, SelectionInteractionController selection,
GameplayInputFrameController gameplayInput, GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat,
ICurrentGameRuntimeEventSink events) ICurrentGameRuntimeEventSink events)
{ {
_session = session ?? throw new ArgumentNullException(nameof(session)); _session = session ?? throw new ArgumentNullException(nameof(session));
@ -63,7 +61,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_gameplayInput = gameplayInput _gameplayInput = gameplayInput
?? throw new ArgumentNullException(nameof(gameplayInput)); ?? throw new ArgumentNullException(nameof(gameplayInput));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_events = events ?? throw new ArgumentNullException(nameof(events)); _events = events ?? throw new ArgumentNullException(nameof(events));
} }
@ -178,8 +175,16 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus status; RuntimeCommandStatus status;
if (command == RuntimeCombatCommand.ToggleMode) if (command == RuntimeCombatCommand.ToggleMode)
{ {
_combat.Toggle(); RuntimeCombatModeRequestResult result =
status = RuntimeCommandStatus.Accepted; _actions.CombatMode.Toggle();
status = result.Status switch
{
RuntimeCombatModeRequestStatus.Sent =>
RuntimeCommandStatus.Accepted,
RuntimeCombatModeRequestStatus.Inactive =>
RuntimeCommandStatus.Inactive,
_ => RuntimeCommandStatus.Rejected,
};
} }
else else
{ {
@ -190,6 +195,49 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(status); 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( public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command) RuntimeMovementCommand command)

View file

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

View file

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

View file

@ -1,157 +0,0 @@
using System;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
/// <summary>
/// App-layer owner for retail cast intent. It validates the client-owned
/// selection rules, stops movement, and emits exactly one targeted or
/// untargeted request. ACE owns every gameplay result after that boundary.
/// </summary>
/// <remarks>
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
/// does not consume mana/components or start local effects.
/// </remarks>
public sealed class SpellCastingController
{
private readonly Spellbook _spellbook;
private readonly Func<uint?> _selectedObject;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
private readonly Func<uint, bool> _hasRequiredComponents;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public SpellCastingController(
Spellbook spellbook,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Func<uint, SpellMetadata, bool>? targetCompatible = null,
Func<uint, bool>? hasRequiredComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null,
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
_targetCompatible = targetCompatible ?? ((_, _) => true);
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
_incrementBusy = incrementBusy ?? (() => { });
_canSend = canSend ?? (() => true);
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
public bool IsTargetReady(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selectedObject() is uint target and not 0u
&& _targetCompatibleSilent(target, spell);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_displayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_hasRequiredComponents(spellId))
{
_displayMessage("You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _localPlayerId();
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selectedObject();
untargeted = false;
if (target is null or 0)
{
_displayMessage("You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_targetCompatible(target.Value, spell))
return CastRequestResult.IncompatibleTarget;
}
if (!_canSend())
{
_displayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_stopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_sendUntargeted(spellId);
else
_sendTargeted(target!.Value, spellId);
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
// busy reference only after Event_Cast has been emitted. The
// matching server UseDone event decrements it.
_incrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
return CastRequestResult.Sent;
}
public void Reset()
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,20 @@ using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime; 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( public readonly record struct RuntimeActionSnapshot(
long SelectionRevision, long SelectionRevision,
uint SelectedObjectId, uint SelectedObjectId,
@ -14,7 +28,9 @@ public readonly record struct RuntimeActionSnapshot(
long InteractionRevision, long InteractionRevision,
InteractionModeKind InteractionMode, InteractionModeKind InteractionMode,
uint InteractionSourceObjectId, uint InteractionSourceObjectId,
RuntimeInteractionTransactionSnapshot InteractionTransactions); RuntimeInteractionTransactionSnapshot InteractionTransactions,
RuntimeCombatAttackSnapshot CombatAttack = default,
RuntimeSpellCastSnapshot Magic = default);
public interface IRuntimeActionView public interface IRuntimeActionView
{ {

View file

@ -112,6 +112,19 @@ public interface IRuntimeCombatCommands
RuntimeCommandResult Execute( RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration, RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command); 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 public interface IRuntimeMovementCommands
@ -251,6 +264,8 @@ public interface IGameRuntimeCommands
IRuntimeCombatCommands Combat { get; } IRuntimeCombatCommands Combat { get; }
IRuntimeMagicCommands Magic { get; }
IRuntimeMovementCommands Movement { get; } IRuntimeMovementCommands Movement { get; }
IRuntimeChatCommands Chat { get; } IRuntimeChatCommands Chat { get; }

View file

@ -7,16 +7,17 @@ public readonly record struct RuntimeEventStamp(
public enum RuntimeCommandDomain public enum RuntimeCommandDomain
{ {
Session, Session = 0,
Selection, Selection = 1,
Combat, Combat = 2,
Movement, Movement = 3,
Chat, Chat = 4,
Portal, Portal = 5,
InventoryState, InventoryState = 6,
Spellbook, Spellbook = 7,
Character, Character = 8,
Social, Social = 9,
Magic = 10,
} }
public readonly record struct RuntimeLifecycleDelta( public readonly record struct RuntimeLifecycleDelta(
@ -176,7 +177,17 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" + $"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" + $"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" + $"{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}:" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.Core.Net; using AcDream.Core.Net;
namespace AcDream.App.Tests.Combat; namespace AcDream.App.Tests.Combat;
@ -152,7 +153,7 @@ public sealed class LiveCombatAttackOperationsTests
public void Show(string message) => Messages.Add(message); 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 CanStartValue { get; init; } = true;
public bool CanStartAttack() => CanStartValue; public bool CanStartAttack() => CanStartValue;

View file

@ -1,6 +1,7 @@
using AcDream.App.Combat; using AcDream.App.Combat;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Combat; namespace AcDream.App.Tests.Combat;
@ -148,11 +149,13 @@ public sealed class LiveCombatModeCommandControllerTests
Authority = new FakeAuthority(Calls); Authority = new FakeAuthority(Calls);
Equipment = new FakeEquipment(); Equipment = new FakeEquipment();
Intent = new FakeIntent(Calls); Intent = new FakeIntent(Calls);
Controller = new LiveCombatModeCommandController( var operations = new LiveCombatModeOperations(
Authority, Authority,
Equipment, Equipment,
Combat, Intent);
Intent, Runtime = new RuntimeCombatModeState(Combat, operations);
Controller = new RuntimeCombatModeCommandAdapter(
Runtime,
line => Calls.Add($"log:{line}"), line => Calls.Add($"log:{line}"),
line => Calls.Add($"toast:{line}"), line => Calls.Add($"toast:{line}"),
line => Calls.Add($"system:{line}")); line => Calls.Add($"system:{line}"));
@ -163,7 +166,8 @@ public sealed class LiveCombatModeCommandControllerTests
public FakeEquipment Equipment { get; } public FakeEquipment Equipment { get; }
public CombatState Combat { get; } = new(); public CombatState Combat { get; } = new();
public FakeIntent Intent { get; } public FakeIntent Intent { get; }
public LiveCombatModeCommandController Controller { get; } public RuntimeCombatModeState Runtime { get; }
public RuntimeCombatModeCommandAdapter Controller { get; }
} }
private sealed class FakeAuthority(List<string> calls) private sealed class FakeAuthority(List<string> calls)

View file

@ -3,9 +3,14 @@ using AcDream.App.Combat;
using AcDream.App.Composition; using AcDream.App.Composition;
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.App.World; 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; namespace AcDream.App.Tests.Composition;
@ -17,7 +22,6 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint.InputCaptureBound, InteractionRetainedUiCompositionPoint.InputCaptureBound,
InteractionRetainedUiCompositionPoint.CursorAssetsCreated, InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
InteractionRetainedUiCompositionPoint.CharacterSheetCreated, InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
InteractionRetainedUiCompositionPoint.MouseInputWired, InteractionRetainedUiCompositionPoint.MouseInputWired,
InteractionRetainedUiCompositionPoint.KeyboardInputWired, InteractionRetainedUiCompositionPoint.KeyboardInputWired,
InteractionRetainedUiCompositionPoint.UiAssetsCreated, InteractionRetainedUiCompositionPoint.UiAssetsCreated,
@ -37,14 +41,15 @@ public sealed class InteractionRetainedUiCompositionTests
Assert.Equal( Assert.Equal(
[ [
InteractionRetainedUiCompositionPoint.LateBindingsCreated, InteractionRetainedUiCompositionPoint.LateBindingsCreated,
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated, InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated, InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated, InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
.. UiPoints, .. UiPoints,
InteractionRetainedUiCompositionPoint.ResultPublished, InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points); ], fixture.Points);
Assert.NotNull(result.RetainedUi); Assert.NotNull(result.RetainedUi);
Assert.NotNull(result.Magic);
Assert.Empty(fixture.Factory.Releases); Assert.Empty(fixture.Factory.Releases);
} }
@ -56,13 +61,14 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiResult result = fixture.Compose(); InteractionRetainedUiResult result = fixture.Compose();
Assert.Null(result.RetainedUi); Assert.Null(result.RetainedUi);
Assert.NotNull(result.Magic);
Assert.Equal( Assert.Equal(
[ [
InteractionRetainedUiCompositionPoint.LateBindingsCreated, InteractionRetainedUiCompositionPoint.LateBindingsCreated,
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
InteractionRetainedUiCompositionPoint.CombatTargetCreated, InteractionRetainedUiCompositionPoint.CombatTargetCreated,
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated, InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
InteractionRetainedUiCompositionPoint.ItemInteractionCreated, InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
InteractionRetainedUiCompositionPoint.RetainedUiDisabled, InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
InteractionRetainedUiCompositionPoint.ResultPublished, InteractionRetainedUiCompositionPoint.ResultPublished,
], fixture.Points); ], fixture.Points);
@ -108,14 +114,12 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint point) InteractionRetainedUiCompositionPoint point)
{ {
var acquired = new List<string> { "late bindings" }; var acquired = new List<string> { "late bindings" };
if (point >= InteractionRetainedUiCompositionPoint.CombatAttackCreated)
acquired.Add("combat attack");
if (point >= InteractionRetainedUiCompositionPoint.CombatTargetCreated)
acquired.Add("combat target");
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated) if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
acquired.Add("external container"); acquired.Add("external container");
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated) if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
acquired.Add("item interaction"); acquired.Add("item interaction");
if (point >= InteractionRetainedUiCompositionPoint.MagicRuntimeCreated)
acquired.Add("magic runtime");
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired) if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
acquired.Add("retained UI lease"); acquired.Add("retained UI lease");
acquired.Reverse(); acquired.Reverse();
@ -132,10 +136,9 @@ public sealed class InteractionRetainedUiCompositionTests
Assert.Equal( Assert.Equal(
[ [
"retained UI lease", "retained UI lease",
"magic runtime",
"item interaction", "item interaction",
"external container", "external container",
"combat target",
"combat attack",
"late bindings", "late bindings",
], fixture.Factory.Releases); ], fixture.Factory.Releases);
} }
@ -172,6 +175,13 @@ public sealed class InteractionRetainedUiCompositionTests
Publication = new Publication(publicationFailure); Publication = new Publication(publicationFailure);
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null) RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
with { RetailUi = retailUi }; with { RetailUi = retailUi };
var actions = new RuntimeActionState(
new InventoryTransactionState(new ClientObjectTable()),
new Spellbook(),
new NoopCombatOperations(),
new NoopCombatTargetOperations(),
new NoopCombatModeOperations(),
new NoopSpellOperations());
Dependencies = new InteractionRetainedUiDependencies( Dependencies = new InteractionRetainedUiDependencies(
Options: options, Options: options,
Gl: null!, Gl: null!,
@ -186,8 +196,10 @@ public sealed class InteractionRetainedUiCompositionTests
RetainedInputCapture: null!, RetainedInputCapture: null!,
InputDispatcher: null, InputDispatcher: null,
Settings: null!, Settings: null!,
Actions: null!, Actions: actions,
CombatAttackOperations: null!, CombatAttackOperations: new NoopCombatOperations(),
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),
SpellCastOperations: new RuntimeSpellCastOperationsSlot(),
Inventory: null!, Inventory: null!,
MagicCatalog: null!, MagicCatalog: null!,
Character: null!, Character: null!,
@ -233,14 +245,10 @@ public sealed class InteractionRetainedUiCompositionTests
public List<string> Releases { get; } = []; public List<string> Releases { get; } = [];
public int RetainedUiCalls { get; private set; } public int RetainedUiCalls { get; private set; }
public CombatAttackController CreateCombatAttack( public IDisposable BindCombatTarget(
InteractionRetainedUiDependencies dependencies) =>
Resource<CombatAttackController>("combat attack");
public CombatTargetController CreateCombatTarget(
InteractionRetainedUiDependencies dependencies, InteractionRetainedUiDependencies dependencies,
DeferredSelectionUiAuthority selection) => DeferredSelectionUiAuthority selection) =>
Resource<CombatTargetController>("combat target"); new NoopDisposable();
public ExternalContainerLifecycleController CreateExternalContainerLifecycle( public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
InteractionRetainedUiDependencies dependencies, InteractionRetainedUiDependencies dependencies,
@ -252,12 +260,19 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionUiLateBindings lateBindings) => InteractionUiLateBindings lateBindings) =>
Resource<ItemInteractionController>("item interaction"); Resource<ItemInteractionController>("item interaction");
public MagicRuntime CreateMagicRuntime(
InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings,
ItemInteractionController itemInteraction) =>
Resource<MagicRuntime>("magic runtime");
public RetainedUiComposition CreateRetainedUi( public RetainedUiComposition CreateRetainedUi(
InteractionRetainedUiDependencies dependencies, InteractionRetainedUiDependencies dependencies,
InteractionUiLateBindings lateBindings, InteractionUiLateBindings lateBindings,
RetailUiRuntimeLease lease, RetailUiRuntimeLease lease,
CombatAttackController combatAttack, RuntimeCombatAttackState combatAttack,
ItemInteractionController itemInteraction, ItemInteractionController itemInteraction,
MagicRuntime magic,
Action<InteractionRetainedUiCompositionPoint> checkpoint) Action<InteractionRetainedUiCompositionPoint> checkpoint)
{ {
RetainedUiCalls++; RetainedUiCalls++;
@ -270,7 +285,6 @@ public sealed class InteractionRetainedUiCompositionTests
Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(), Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(),
Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(), Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(),
Stub<CharacterSheetProvider>(), Stub<CharacterSheetProvider>(),
Stub<AcDream.App.Spells.MagicRuntime>(),
null); 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<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(CombatMode mode) { }
}
private sealed class Publication(bool fail) private sealed class Publication(bool fail)
: IGameWindowInteractionRetainedUiPublication : IGameWindowInteractionRetainedUiPublication
{ {

View file

@ -240,6 +240,7 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeSessionCommands Session => null!; public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!; public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!; public IRuntimeCombatCommands Combat => null!;
public IRuntimeMagicCommands Magic => null!;
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!; IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!; IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!; IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input; namespace AcDream.App.Tests.Input;
@ -184,6 +185,13 @@ public sealed class GameplayInputCommandControllerTests
RuntimeCommandStatus.Accepted, RuntimeCommandStatus.Accepted,
expectedGeneration); expectedGeneration);
} }
public RuntimeCommandResult ExecuteAttack(
RuntimeGenerationToken expectedGeneration,
in RuntimeCombatAttackInput command) =>
new(
RuntimeCommandStatus.Unsupported,
expectedGeneration);
} }
private sealed class FakeRuntimeView : IGameRuntimeView private sealed class FakeRuntimeView : IGameRuntimeView

View file

@ -5,6 +5,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.App.Runtime; using AcDream.App.Runtime;
using AcDream.App.Spells;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.Update; using AcDream.App.Update;
@ -16,6 +17,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
@ -97,7 +99,12 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.True(portal.Accepted); Assert.True(portal.Accepted);
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId); Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
Assert.True(harness.MovementInput.AutoRunActive); 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( Assert.Contains(
harness.Commands.Published, harness.Commands.Published,
static command => command is SendChatCmd static command => command is SendChatCmd
@ -178,10 +185,89 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(active, stopped.RetiredGeneration); Assert.Equal(active, stopped.RetiredGeneration);
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration); Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status); 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); 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] [Fact]
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace() public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
{ {
@ -668,6 +754,7 @@ public sealed class CurrentGameRuntimeAdapterTests
private readonly ItemInteractionController _items; private readonly ItemInteractionController _items;
private readonly LiveSessionController _session; private readonly LiveSessionController _session;
private readonly IDisposable _combatModeBinding;
public Harness() public Harness()
{ {
@ -681,14 +768,26 @@ public sealed class CurrentGameRuntimeAdapterTests
EntityObjects); EntityObjects);
Objects = EntityObjects.Objects; Objects = EntityObjects.Objects;
Communication = new RuntimeCommunicationState(); 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(); MovementInput = new DispatcherMovementInputSource();
GameplayInput = new GameplayInputFrameController( GameplayInput = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
MovementInput, MovementInput,
mouseLook: null, mouseLook: null,
new NoopCombatInput()); new NoopCombatInput());
Combat = new RecordingCombatCommand();
Clock = new UpdateFrameClock(); Clock = new UpdateFrameClock();
WorldReveal = new WorldRevealCoordinator( WorldReveal = new WorldRevealCoordinator(
static (_, _) => true, static (_, _) => true,
@ -737,8 +836,7 @@ public sealed class CurrentGameRuntimeAdapterTests
WorldReveal, WorldReveal,
Clock, Clock,
selectionController, selectionController,
GameplayInput, GameplayInput);
Combat);
} }
public RuntimeOptions Options { get; } public RuntimeOptions Options { get; }
@ -750,11 +848,14 @@ public sealed class CurrentGameRuntimeAdapterTests
public ClientObjectTable Objects { get; } public ClientObjectTable Objects { get; }
public RuntimeCommunicationState Communication { get; } public RuntimeCommunicationState Communication { get; }
public ChatLog Chat => Communication.Chat; public ChatLog Chat => Communication.Chat;
public CombatAttackOperationsSlot CombatAttackOperations { get; }
public RuntimeCombatModeOperationsSlot CombatModeOperations { get; }
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
public RuntimeActionState Actions { get; } public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection; public SelectionState Selection => Actions.Selection;
public DispatcherMovementInputSource MovementInput { get; } public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; } public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatCommand Combat { get; } public RecordingCombatModeOperations CombatMode { get; }
public UpdateFrameClock Clock { get; } public UpdateFrameClock Clock { get; }
public WorldRevealCoordinator WorldReveal { get; } public WorldRevealCoordinator WorldReveal { get; }
public RecordingCommandRouting Commands { get; } public RecordingCommandRouting Commands { get; }
@ -769,12 +870,72 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication.Dispose(); Communication.Dispose();
_session.Dispose(); _session.Dispose();
_items.Dispose(); _items.Dispose();
_combatModeBinding.Dispose();
Actions.Dispose(); Actions.Dispose();
InventoryState.Dispose(); InventoryState.Dispose();
Entities.Clear(); 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<string> 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<string> 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( private static LiveSessionHost CreateHost(
LiveSessionController controller, LiveSessionController controller,
RecordingCommandRouting commands, RecordingCommandRouting commands,
@ -1031,10 +1192,15 @@ public sealed class CurrentGameRuntimeAdapterTests
public void Dispose() => _active = false; public void Dispose() => _active = false;
} }
internal sealed class RecordingCombatCommand : ILiveCombatModeCommand internal sealed class RecordingCombatModeOperations
: IRuntimeCombatModeOperations
{ {
public int ToggleCount { get; private set; } public List<AcDream.Core.Combat.CombatMode> Sent { get; } = [];
public void Toggle() => ToggleCount++; public bool IsInWorld => true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(
AcDream.Core.Combat.CombatMode mode) => Sent.Add(mode);
} }
private sealed class NoopCombatInput : ICombatInputFrameController private sealed class NoopCombatInput : ICombatInputFrameController

View file

@ -19,7 +19,7 @@ public sealed class RuntimeActionOwnershipTests
gameWindow, gameWindow,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);", "_runtimeActions = new RuntimeActionState(",
gameWindow, gameWindow,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
@ -61,6 +61,18 @@ public sealed class RuntimeActionOwnershipTests
Assert.Empty(Regex.Matches( Assert.Empty(Regex.Matches(
production, production,
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeInteractionTransactionState\s*\(")); @"\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( Assert.Equal(
1, 1,
Regex.Matches( Regex.Matches(
@ -104,6 +116,11 @@ public sealed class RuntimeActionOwnershipTests
Assert.Contains("d.Actions.Transactions,", ui, StringComparison.Ordinal); Assert.Contains("d.Actions.Transactions,", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal); Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Combat", 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.Selection", session, StringComparison.Ordinal);
Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal); Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal);
Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal); Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal);

View file

@ -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<string>();
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<InvalidOperationException>(() => 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<uint>? sendUntargeted = null,
Action<uint, uint>? sendTargeted = null,
Action<string>? display = null,
Func<uint, bool>? hasComponents = null,
Action? incrementBusy = null,
Func<bool>? 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;
}
}

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -33,7 +34,7 @@ public sealed class CombatUiControllerTests
Assert.False(high.Selected); Assert.False(high.Selected);
Assert.True(medium.Selected); Assert.True(medium.Selected);
Assert.False(low.Selected); Assert.False(low.Selected);
Assert.Equal(CombatAttackController.InitialDesiredPower, power.ScalarPosition); Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, power.ScalarPosition);
} }
[Fact] [Fact]
@ -123,7 +124,7 @@ public sealed class CombatUiControllerTests
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View", "Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
"High", "Medium", "Low"); "High", "Medium", "Low");
private static CombatAttackController CreateAttacks( private static RuntimeCombatAttackState CreateAttacks(
CombatState combat, CombatState combat,
Func<double> now, Func<double> now,
List<(AttackHeight Height, float Power)> sent) List<(AttackHeight Height, float Power)> sent)

View file

@ -184,7 +184,7 @@ public class SelectedObjectControllerTests
h.StackMap[Replacement] = 1u; h.StackMap[Replacement] = 1u;
h.Selection.Select(Dead, SelectionChangeSource.World); 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. // production. Its clear handler selects a replacement reentrantly.
h.Selection.Changed += transition => h.Selection.Changed += transition =>
{ {

View file

@ -4,6 +4,7 @@ using AcDream.App.UI.Layout;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -330,14 +331,17 @@ public sealed class SpellcastingUiControllerTests
SelectionState? selection = null, SelectionState? selection = null,
Action<uint>? examineSpell = null) Action<uint>? examineSpell = null)
{ {
var casting = new SpellCastingController( SelectionState selectionState = selection ?? new SelectionState();
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { }); var casting = new RuntimeSpellCastState(
spellbook,
selectionState,
new NoopSpellCastOperations());
return SpellcastingUiController.Bind( return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u, layout, spellbook, casting, objects, () => 1u,
spellId => spellId, spellId => spellId,
item => item.ObjectId, item => item.ObjectId,
useItem, useItem,
selection ?? new SelectionState(), selectionState,
(tab, position, spellId) => (tab, position, spellId) =>
{ {
spellbook.SetFavorite(tab, position, spellId); spellbook.SetFavorite(tab, position, spellId);
@ -349,6 +353,22 @@ public sealed class SpellcastingUiControllerTests
examineSpell); 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) private static void ApplyAnchors(UiElement parent)
{ {
foreach (UiElement child in parent.Children) foreach (UiElement child in parent.Children)

View file

@ -597,11 +597,11 @@ public sealed class UpdateFrameOrchestratorTests
field => field.FieldType == typeof(Silk.NET.Input.IMouse)); field => field.FieldType == typeof(Silk.NET.Input.IMouse));
FieldInfo combatOperations = Assert.Single( FieldInfo combatOperations = Assert.Single(
typeof(AcDream.App.Combat.CombatAttackController).GetFields( typeof(AcDream.Runtime.Gameplay.RuntimeCombatAttackState).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic), BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_operations"); field => field.Name == "_operations");
Assert.Equal( Assert.Equal(
typeof(AcDream.App.Combat.ICombatAttackOperations), typeof(AcDream.Runtime.Gameplay.IRuntimeCombatAttackOperations),
combatOperations.FieldType); combatOperations.FieldType);
Assert.DoesNotContain( Assert.DoesNotContain(
typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields( typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields(

View file

@ -192,7 +192,8 @@ public sealed class GameRuntimeContractTests
Assert.Contains("social=9:10:11", entry.Text); Assert.Contains("social=9:10:11", entry.Text);
Assert.Contains( Assert.Contains(
"actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" + "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); entry.Text);
} }
@ -208,6 +209,10 @@ public sealed class GameRuntimeContractTests
typeof(RuntimeMovementSkillState), typeof(RuntimeMovementSkillState),
typeof(RuntimeActionState), typeof(RuntimeActionState),
typeof(InteractionState), typeof(InteractionState),
typeof(RuntimeCombatAttackState),
typeof(RuntimeCombatTargetState),
typeof(RuntimeCombatModeState),
typeof(RuntimeSpellCastState),
]; ];
foreach (Type owner in owners) foreach (Type owner in owners)

View file

@ -11,7 +11,7 @@ public sealed class RuntimeActionStateTests
public void ViewProjectsTheExactCanonicalChildrenAndRevisions() public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
{ {
using var inventory = NewInventoryTransactions(); using var inventory = NewInventoryTransactions();
using var actions = new RuntimeActionState(inventory); using var actions = RuntimeActionTestFactory.Create(inventory);
actions.Selection.Select( actions.Selection.Select(
0x50000001u, 0x50000001u,
@ -41,7 +41,7 @@ public sealed class RuntimeActionStateTests
public void ResetSessionConvergesEveryChildAfterObserverFailures() public void ResetSessionConvergesEveryChildAfterObserverFailures()
{ {
using var inventory = NewInventoryTransactions(); using var inventory = NewInventoryTransactions();
var actions = new RuntimeActionState(inventory); var actions = RuntimeActionTestFactory.Create(inventory);
actions.Selection.Select( actions.Selection.Select(
0x50000001u, 0x50000001u,
SelectionChangeSource.World); SelectionChangeSource.World);
@ -73,7 +73,7 @@ public sealed class RuntimeActionStateTests
public void DisposeIsTerminalAndConvergedAfterObserverFailures() public void DisposeIsTerminalAndConvergedAfterObserverFailures()
{ {
using var inventory = NewInventoryTransactions(); using var inventory = NewInventoryTransactions();
var actions = new RuntimeActionState(inventory); var actions = RuntimeActionTestFactory.Create(inventory);
actions.Selection.Select( actions.Selection.Select(
0x50000001u, 0x50000001u,
SelectionChangeSource.World); SelectionChangeSource.World);
@ -100,8 +100,8 @@ public sealed class RuntimeActionStateTests
{ {
using var firstInventory = NewInventoryTransactions(); using var firstInventory = NewInventoryTransactions();
using var secondInventory = NewInventoryTransactions(); using var secondInventory = NewInventoryTransactions();
using var first = new RuntimeActionState(firstInventory); using var first = RuntimeActionTestFactory.Create(firstInventory);
using var second = new RuntimeActionState(secondInventory); using var second = RuntimeActionTestFactory.Create(secondInventory);
first.Selection.Select( first.Selection.Select(
0x50000001u, 0x50000001u,
@ -131,7 +131,19 @@ public sealed class RuntimeActionStateTests
OutboundCount: 0, OutboundCount: 0,
HasPendingPickup: false, HasPendingPickup: false,
PendingPickupToken: 0u, 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); second.View.Snapshot);
} }

View file

@ -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<double>? 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<ClientObject> 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() { }
}
}

View file

@ -1,10 +1,9 @@
using AcDream.App.Combat;
using AcDream.Core.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] [Fact]
public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower() public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
@ -76,11 +75,13 @@ public sealed class CombatAttackControllerTests
using var controller = Create(combat, () => now, sent); using var controller = Create(combat, () => now, sent);
combat.SetCombatMode(CombatMode.Melee); combat.SetCombatMode(CombatMode.Melee);
Assert.True(controller.HandleInputAction( Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
InputAction.CombatLowAttack, ActivationType.Press)); RuntimeCombatAttackCommand.LowAttack,
RuntimeInputActivation.Press)));
now = 1.5d; now = 1.5d;
Assert.True(controller.HandleInputAction( Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
InputAction.CombatLowAttack, ActivationType.Release)); RuntimeCombatAttackCommand.LowAttack,
RuntimeInputActivation.Release)));
Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height); Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
} }
@ -108,7 +109,7 @@ public sealed class CombatAttackControllerTests
double now = 0d; double now = 0d;
bool ready = false; bool ready = false;
var combat = new CombatState(); var combat = new CombatState();
using var controller = new CombatAttackController( using var controller = new RuntimeCombatAttackState(
combat, combat,
() => true, () => true,
(_, _) => true, (_, _) => true,
@ -135,7 +136,7 @@ public sealed class CombatAttackControllerTests
int cancels = 0; int cancels = 0;
var sent = new List<(AttackHeight Height, float Power)>(); var sent = new List<(AttackHeight Height, float Power)>();
var combat = new CombatState(); var combat = new CombatState();
using var controller = new CombatAttackController( using var controller = new RuntimeCombatAttackState(
combat, combat,
canStartAttack: () => true, canStartAttack: () => true,
sendAttack: (height, power) => sendAttack: (height, power) =>
@ -153,8 +154,9 @@ public sealed class CombatAttackControllerTests
controller.ReleaseAttack(); controller.ReleaseAttack();
Assert.Single(sent); Assert.Single(sent);
controller.HandleMovementInput( controller.HandleCommand(new RuntimeCombatAttackInput(
InputAction.MovementForward, ActivationType.Press); RuntimeCombatAttackCommand.AbortForMovement,
RuntimeInputActivation.Press));
combat.OnAttackDone(1u, 0u); combat.OnAttackDone(1u, 0u);
Assert.Equal(1, cancels); Assert.Equal(1, cancels);
@ -168,7 +170,7 @@ public sealed class CombatAttackControllerTests
{ {
int cancels = 0; int cancels = 0;
var combat = new CombatState(); var combat = new CombatState();
using var controller = new CombatAttackController( using var controller = new RuntimeCombatAttackState(
combat, combat,
canStartAttack: () => true, canStartAttack: () => true,
sendAttack: (_, _) => true, sendAttack: (_, _) => true,
@ -184,8 +186,8 @@ public sealed class CombatAttackControllerTests
{ {
var events = new List<string>(); var events = new List<string>();
var combat = new CombatState(); var combat = new CombatState();
CombatAttackController? controller = null; RuntimeCombatAttackState? controller = null;
controller = new CombatAttackController( controller = new RuntimeCombatAttackState(
combat, combat,
canStartAttack: () => true, canStartAttack: () => true,
sendAttack: (_, _) => sendAttack: (_, _) =>
@ -221,7 +223,7 @@ public sealed class CombatAttackControllerTests
double now = 1d; double now = 1d;
int cancels = 0; int cancels = 0;
var combat = new CombatState(); var combat = new CombatState();
using var controller = new CombatAttackController( using var controller = new RuntimeCombatAttackState(
combat, combat,
canStartAttack: () => true, canStartAttack: () => true,
sendAttack: (_, _) => true, sendAttack: (_, _) => true,
@ -239,11 +241,11 @@ public sealed class CombatAttackControllerTests
Assert.Equal(0f, controller.RequestedAttackPower); Assert.Equal(0f, controller.RequestedAttackPower);
Assert.Equal(0f, controller.PowerBarLevel); Assert.Equal(0f, controller.PowerBarLevel);
Assert.Equal(AttackHeight.Medium, controller.RequestedHeight); Assert.Equal(AttackHeight.Medium, controller.RequestedHeight);
Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower); Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, controller.DesiredPower);
Assert.Equal(0, cancels); Assert.Equal(0, cancels);
} }
private static CombatAttackController Create( private static RuntimeCombatAttackState Create(
CombatState combat, CombatState combat,
Func<double> now, Func<double> now,
List<(AttackHeight Height, float Power)> sent, List<(AttackHeight Height, float Power)> sent,

View file

@ -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<InvalidOperationException>(() => 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<ClientObject> Equipment { get; } = [];
public List<string> Trace { get; } = [];
public IReadOnlyList<ClientObject> 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");
}
}
}

View file

@ -1,11 +1,11 @@
using AcDream.App.Combat;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; 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] [Fact]
public void DeadSelectedTarget_AutoTargetEnabled_SelectsClosestReplacement() public void DeadSelectedTarget_AutoTargetEnabled_SelectsClosestReplacement()
@ -15,16 +15,15 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World); selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0; int calls = 0;
using var controller = new CombatTargetController( using var controller = new RuntimeCombatTargetState(
combat, combat,
selection, selection,
autoTarget: () => true, new Operations(true, () =>
selectClosestTarget: () =>
{ {
calls++; calls++;
selection.Select(0x50000002u, SelectionChangeSource.System); selection.Select(0x50000002u, SelectionChangeSource.System);
return 0x50000002u; return 0x50000002u;
}); }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead); controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@ -40,8 +39,10 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World); selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0; int calls = 0;
using var controller = new CombatTargetController( using var controller = new RuntimeCombatTargetState(
combat, selection, () => false, () => { calls++; return null; }); combat,
selection,
new Operations(false, () => { calls++; return null; }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead); controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@ -56,8 +57,10 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World); selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0; int calls = 0;
using var controller = new CombatTargetController( using var controller = new RuntimeCombatTargetState(
combat, selection, () => true, () => { calls++; return null; }); combat,
selection,
new Operations(true, () => { calls++; return null; }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead); controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@ -72,8 +75,12 @@ public sealed class CombatTargetControllerTests
combat.SetCombatMode(CombatMode.Missile); combat.SetCombatMode(CombatMode.Missile);
var selection = new SelectionState(); var selection = new SelectionState();
selection.Select(0x50000002u, SelectionChangeSource.World); selection.Select(0x50000002u, SelectionChangeSource.World);
using var controller = new CombatTargetController( using var controller = new RuntimeCombatTargetState(
combat, selection, () => true, () => throw new InvalidOperationException()); combat,
selection,
new Operations(
true,
() => throw new InvalidOperationException()));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead); controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
@ -88,12 +95,23 @@ public sealed class CombatTargetControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
selection.Select(0x50000002u, SelectionChangeSource.World); selection.Select(0x50000002u, SelectionChangeSource.World);
int calls = 0; int calls = 0;
using var controller = new CombatTargetController( using var controller = new RuntimeCombatTargetState(
combat, selection, () => true, () => { calls++; return null; }); combat,
selection,
new Operations(true, () => { calls++; return null; }));
selection.Reset(); selection.Reset();
Assert.Equal(0, calls); Assert.Equal(0, calls);
Assert.Null(selection.SelectedObjectId); Assert.Null(selection.SelectedObjectId);
} }
private sealed class Operations(
bool autoTarget,
Func<uint?> selectClosestTarget)
: IRuntimeCombatTargetOperations
{
public bool AutoTarget { get; } = autoTarget;
public uint? SelectClosestTarget() => selectClosestTarget();
}
} }

View file

@ -15,7 +15,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = new RuntimeActionState(inventory.Transactions); var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
@ -114,7 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = new RuntimeActionState(inventory.Transactions); var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);

View file

@ -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<string>();
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<InvalidOperationException>(() => 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<uint>? SendUntargetedAction { get; init; }
public Action<uint, uint>? 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();
}
}
}