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:
parent
81b31857c6
commit
20df9d155d
49 changed files with 1949 additions and 634 deletions
|
|
@ -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` |
|
||||||
|
|
|
||||||
189
docs/research/2026-07-26-slice-j5-3-combat-magic-intent.md
Normal file
189
docs/research/2026-07-26-slice-j5-3-combat-magic-intent.md
Normal 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.
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
nextMode = CombatMode.NonCombat;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
DefaultCombatModeDecision defaultDecision =
|
|
||||||
CombatInputPlanner.GetDefaultCombatModeDecision(
|
|
||||||
_equipment.GetOrderedEquipment());
|
|
||||||
nextMode = CombatInputPlanner.ToggleMode(
|
|
||||||
currentMode,
|
|
||||||
defaultDecision.Mode);
|
|
||||||
|
|
||||||
// GetDefaultCombatMode prints this notice before SetCombatMode.
|
|
||||||
// SetCombatMode then sees NonCombat == NonCombat and does no work.
|
|
||||||
if (defaultDecision.IncompatibleHeldItem is { } held)
|
|
||||||
{
|
|
||||||
string notice =
|
|
||||||
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
|
|
||||||
_log($"combat: {notice}");
|
_log($"combat: {notice}");
|
||||||
_systemMessage(notice);
|
_systemMessage(notice);
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs
Normal file
63
src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs
Normal file
142
src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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}")));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
94
src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs
Normal file
94
src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
161
src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs
Normal file
161
src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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!;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue