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-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
||||
| AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
||||
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
|
||||
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
|
||||
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
||||
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
||||
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
|
||||
|
|
@ -208,7 +208,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
|
||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
||||
|
||||
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
|
||||
|
|
|
|||
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.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
|
@ -54,11 +55,12 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
|||
public void Show(string message) => _viewModel?.AddToast(message);
|
||||
}
|
||||
|
||||
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
||||
internal sealed class CombatAttackOperationsSlot
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
private ICombatAttackOperations? _owner;
|
||||
private IRuntimeCombatAttackOperations? _owner;
|
||||
|
||||
public void Bind(ICombatAttackOperations owner)
|
||||
public void Bind(IRuntimeCombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null && !ReferenceEquals(_owner, owner))
|
||||
|
|
@ -67,7 +69,7 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
_owner = owner;
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(ICombatAttackOperations owner)
|
||||
public IDisposable BindOwned(IRuntimeCombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null)
|
||||
|
|
@ -77,7 +79,7 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
return new Binding(this, owner);
|
||||
}
|
||||
|
||||
private void Unbind(ICombatAttackOperations expected)
|
||||
private void Unbind(IRuntimeCombatAttackOperations expected)
|
||||
{
|
||||
if (ReferenceEquals(_owner, expected))
|
||||
_owner = null;
|
||||
|
|
@ -95,11 +97,11 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private CombatAttackOperationsSlot? _slot;
|
||||
private readonly ICombatAttackOperations _expected;
|
||||
private readonly IRuntimeCombatAttackOperations _expected;
|
||||
|
||||
public Binding(
|
||||
CombatAttackOperationsSlot slot,
|
||||
ICombatAttackOperations expected)
|
||||
IRuntimeCombatAttackOperations expected)
|
||||
{
|
||||
_slot = slot;
|
||||
_expected = expected;
|
||||
|
|
@ -114,7 +116,8 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
/// Production combat request dependencies. The attack state machine retains
|
||||
/// this typed owner rather than callbacks into the application window.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
|
||||
internal sealed class LiveCombatAttackOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly ICombatAttackTargetSource _targets;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.App.Net;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
|
@ -164,37 +165,96 @@ internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the input-facing combat-mode command. The choice itself remains the
|
||||
/// retail port in <see cref="CombatInputPlanner"/>:
|
||||
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
|
||||
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
|
||||
/// authority; the local state update preserves the accepted immediate toolbar
|
||||
/// response while the server confirmation travels through the normal route.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
|
||||
internal sealed class RuntimeCombatModeOperationsSlot
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
private IRuntimeCombatModeOperations? _owner;
|
||||
|
||||
public IDisposable BindOwned(IRuntimeCombatModeOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Runtime combat-mode operations are already bound.");
|
||||
_owner = owner;
|
||||
return new Binding(this, owner);
|
||||
}
|
||||
|
||||
private void Unbind(IRuntimeCombatModeOperations expected)
|
||||
{
|
||||
if (ReferenceEquals(_owner, expected))
|
||||
_owner = null;
|
||||
}
|
||||
|
||||
public bool IsInWorld => _owner?.IsInWorld == true;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
|
||||
_owner?.GetOrderedEquipment() ?? [];
|
||||
public void NotifyExplicitCombatModeRequest() =>
|
||||
_owner?.NotifyExplicitCombatModeRequest();
|
||||
public void SendChangeCombatMode(CombatMode mode) =>
|
||||
_owner?.SendChangeCombatMode(mode);
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private RuntimeCombatModeOperationsSlot? _slot;
|
||||
private readonly IRuntimeCombatModeOperations _expected;
|
||||
|
||||
public Binding(
|
||||
RuntimeCombatModeOperationsSlot slot,
|
||||
IRuntimeCombatModeOperations expected)
|
||||
{
|
||||
_slot = slot;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveCombatModeOperations
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
private readonly ILiveCombatModeAuthority _authority;
|
||||
private readonly ICombatEquipmentSource _equipment;
|
||||
private readonly CombatState _combat;
|
||||
private readonly IExplicitCombatModeIntentSink _itemIntent;
|
||||
|
||||
public LiveCombatModeOperations(
|
||||
ILiveCombatModeAuthority authority,
|
||||
ICombatEquipmentSource equipment,
|
||||
IExplicitCombatModeIntentSink itemIntent)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
|
||||
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
|
||||
}
|
||||
|
||||
public bool IsInWorld => _authority.IsInWorld;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
|
||||
_equipment.GetOrderedEquipment();
|
||||
public void NotifyExplicitCombatModeRequest() =>
|
||||
_itemIntent.NotifyExplicitCombatModeRequest();
|
||||
public void SendChangeCombatMode(CombatMode mode) =>
|
||||
_authority.SendChangeCombatMode(mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App presentation adapter for Runtime's retail combat-mode decision.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeCombatModeCommandAdapter : ILiveCombatModeCommand
|
||||
{
|
||||
private readonly RuntimeCombatModeState _owner;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Action<string> _systemMessage;
|
||||
|
||||
public LiveCombatModeCommandController(
|
||||
ILiveCombatModeAuthority authority,
|
||||
ICombatEquipmentSource equipment,
|
||||
CombatState combat,
|
||||
IExplicitCombatModeIntentSink itemIntent,
|
||||
public RuntimeCombatModeCommandAdapter(
|
||||
RuntimeCombatModeState owner,
|
||||
Action<string>? log = null,
|
||||
Action<string>? toast = null,
|
||||
Action<string>? systemMessage = null)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
|
||||
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
_log = log ?? (_ => { });
|
||||
_toast = toast;
|
||||
_systemMessage = systemMessage ?? (_ => { });
|
||||
|
|
@ -202,48 +262,19 @@ internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
|
|||
|
||||
public void Toggle()
|
||||
{
|
||||
if (!_authority.IsInWorld)
|
||||
RuntimeCombatModeRequestResult result = _owner.Toggle();
|
||||
if (result.Status == RuntimeCombatModeRequestStatus.Inactive)
|
||||
return;
|
||||
|
||||
// Every explicit user combat request supersedes an in-flight auto-wield
|
||||
// settlement, including a request retail rejects before SetCombatMode.
|
||||
_itemIntent.NotifyExplicitCombatModeRequest();
|
||||
|
||||
CombatMode currentMode = _combat.CurrentMode;
|
||||
CombatMode nextMode;
|
||||
if (currentMode != CombatMode.NonCombat)
|
||||
if (result.Status == RuntimeCombatModeRequestStatus.Rejected)
|
||||
{
|
||||
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and
|
||||
// never queries default equipment on this branch.
|
||||
nextMode = CombatMode.NonCombat;
|
||||
}
|
||||
else
|
||||
{
|
||||
DefaultCombatModeDecision defaultDecision =
|
||||
CombatInputPlanner.GetDefaultCombatModeDecision(
|
||||
_equipment.GetOrderedEquipment());
|
||||
nextMode = CombatInputPlanner.ToggleMode(
|
||||
currentMode,
|
||||
defaultDecision.Mode);
|
||||
|
||||
// GetDefaultCombatMode prints this notice before SetCombatMode.
|
||||
// SetCombatMode then sees NonCombat == NonCombat and does no work.
|
||||
if (defaultDecision.IncompatibleHeldItem is { } held)
|
||||
{
|
||||
string notice =
|
||||
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
|
||||
_log($"combat: {notice}");
|
||||
_systemMessage(notice);
|
||||
return;
|
||||
}
|
||||
string notice = result.Notice ?? string.Empty;
|
||||
_log($"combat: {notice}");
|
||||
_systemMessage(notice);
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve the accepted command order: explicit intent was published
|
||||
// above, then send the request and publish local presentation.
|
||||
_authority.SendChangeCombatMode(nextMode);
|
||||
_combat.SetCombatMode(nextMode);
|
||||
|
||||
string message = $"Combat mode {nextMode}";
|
||||
string message = $"Combat mode {result.Mode}";
|
||||
_log($"combat: {message}");
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
RuntimeSettingsController Settings,
|
||||
RuntimeActionState Actions,
|
||||
ICombatAttackOperations CombatAttackOperations,
|
||||
IRuntimeCombatAttackOperations CombatAttackOperations,
|
||||
RuntimeCombatTargetOperationsSlot CombatTargetOperations,
|
||||
RuntimeSpellCastOperationsSlot SpellCastOperations,
|
||||
RuntimeInventoryState Inventory,
|
||||
MagicCatalog MagicCatalog,
|
||||
RuntimeCharacterState Character,
|
||||
|
|
@ -70,14 +72,13 @@ internal sealed record RetainedUiComposition(
|
|||
VitalsVM Vitals,
|
||||
ChatVM Chat,
|
||||
CharacterSheetProvider CharacterSheet,
|
||||
MagicRuntime Magic,
|
||||
FrameScreenshotController? Screenshots);
|
||||
|
||||
internal sealed record InteractionRetainedUiResult(
|
||||
CombatAttackController CombatAttack,
|
||||
CombatTargetController CombatTarget,
|
||||
RuntimeCombatAttackState CombatAttack,
|
||||
ExternalContainerLifecycleController ExternalContainerLifecycle,
|
||||
ItemInteractionController ItemInteraction,
|
||||
MagicRuntime Magic,
|
||||
RetainedUiComposition? RetainedUi,
|
||||
InteractionUiLateBindings LateBindings);
|
||||
|
||||
|
|
@ -89,16 +90,15 @@ internal interface IGameWindowInteractionRetainedUiPublication
|
|||
internal enum InteractionRetainedUiCompositionPoint
|
||||
{
|
||||
LateBindingsCreated,
|
||||
CombatAttackCreated,
|
||||
CombatTargetCreated,
|
||||
ExternalContainerLifecycleCreated,
|
||||
ItemInteractionCreated,
|
||||
MagicRuntimeCreated,
|
||||
RetainedUiDisabled,
|
||||
UiHostAcquired,
|
||||
InputCaptureBound,
|
||||
CursorAssetsCreated,
|
||||
CharacterSheetCreated,
|
||||
MagicRuntimeCreated,
|
||||
MouseInputWired,
|
||||
KeyboardInputWired,
|
||||
UiAssetsCreated,
|
||||
|
|
@ -225,10 +225,7 @@ internal sealed class InteractionUiLateBindings : IDisposable
|
|||
|
||||
internal interface IInteractionRetainedUiCompositionFactory
|
||||
{
|
||||
CombatAttackController CreateCombatAttack(
|
||||
InteractionRetainedUiDependencies dependencies);
|
||||
|
||||
CombatTargetController CreateCombatTarget(
|
||||
IDisposable BindCombatTarget(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
DeferredSelectionUiAuthority selection);
|
||||
|
||||
|
|
@ -240,12 +237,18 @@ internal interface IInteractionRetainedUiCompositionFactory
|
|||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings);
|
||||
|
||||
MagicRuntime CreateMagicRuntime(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings,
|
||||
ItemInteractionController itemInteraction);
|
||||
|
||||
RetainedUiComposition CreateRetainedUi(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings,
|
||||
RetailUiRuntimeLease lease,
|
||||
CombatAttackController combatAttack,
|
||||
RuntimeCombatAttackState combatAttack,
|
||||
ItemInteractionController itemInteraction,
|
||||
MagicRuntime magic,
|
||||
Action<InteractionRetainedUiCompositionPoint> checkpoint);
|
||||
|
||||
void Release(IDisposable resource);
|
||||
|
|
@ -254,19 +257,13 @@ internal interface IInteractionRetainedUiCompositionFactory
|
|||
internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||
: IInteractionRetainedUiCompositionFactory
|
||||
{
|
||||
public CombatAttackController CreateCombatAttack(
|
||||
InteractionRetainedUiDependencies d) =>
|
||||
new(d.Actions.Combat, d.CombatAttackOperations);
|
||||
|
||||
public CombatTargetController CreateCombatTarget(
|
||||
public IDisposable BindCombatTarget(
|
||||
InteractionRetainedUiDependencies d,
|
||||
DeferredSelectionUiAuthority selection) =>
|
||||
new(
|
||||
d.Actions.Combat,
|
||||
d.Actions.Selection,
|
||||
d.CombatTargetOperations.BindOwned(new LiveCombatTargetOperations(
|
||||
autoTarget: () => d.Settings.Gameplay.AutoTarget,
|
||||
selectClosestTarget: () =>
|
||||
selection.SelectClosestCombatTarget(showToast: false));
|
||||
selection.SelectClosestCombatTarget(showToast: false)));
|
||||
|
||||
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
||||
InteractionRetainedUiDependencies d,
|
||||
|
|
@ -339,12 +336,38 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
requestUse: selection.RequestUse);
|
||||
}
|
||||
|
||||
public MagicRuntime CreateMagicRuntime(
|
||||
InteractionRetainedUiDependencies d,
|
||||
InteractionUiLateBindings late,
|
||||
ItemInteractionController itemInteraction) =>
|
||||
MagicRuntime.Create(
|
||||
d.MagicCatalog,
|
||||
d.Actions.SpellCast,
|
||||
d.SpellCastOperations,
|
||||
d.Inventory.Objects,
|
||||
localPlayerId: () => d.PlayerIdentity.ServerGuid,
|
||||
accountName: () => late.Session.AccountName
|
||||
?? d.Options.LiveUser
|
||||
?? string.Empty,
|
||||
stopCompletely: d.CombatAttackOperations.PrepareAttackRequest,
|
||||
sendUntargeted: spellId =>
|
||||
late.Session.CurrentSession?.SendCastUntargetedSpell(spellId),
|
||||
sendTargeted: (target, spellId) =>
|
||||
late.Session.CurrentSession?.SendCastTargetedSpell(
|
||||
target,
|
||||
spellId),
|
||||
displayMessage:
|
||||
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
|
||||
incrementBusy: itemInteraction.IncrementBusyCount,
|
||||
canSend: () => late.Session.IsInWorld);
|
||||
|
||||
public RetainedUiComposition CreateRetainedUi(
|
||||
InteractionRetainedUiDependencies d,
|
||||
InteractionUiLateBindings late,
|
||||
RetailUiRuntimeLease lease,
|
||||
CombatAttackController combatAttack,
|
||||
RuntimeCombatAttackState combatAttack,
|
||||
ItemInteractionController itemInteraction,
|
||||
MagicRuntime magic,
|
||||
Action<InteractionRetainedUiCompositionPoint> checkpoint)
|
||||
{
|
||||
IDisposable? inputCapture = null;
|
||||
|
|
@ -404,27 +427,6 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
credits));
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated);
|
||||
|
||||
MagicRuntime magic = MagicRuntime.Create(
|
||||
d.MagicCatalog,
|
||||
d.Character.Spellbook,
|
||||
d.Inventory.Objects,
|
||||
selectedObject: () =>
|
||||
d.Actions.Selection.SelectedObjectId,
|
||||
localPlayerId: () => d.PlayerIdentity.ServerGuid,
|
||||
accountName: () => late.Session.AccountName
|
||||
?? d.Options.LiveUser
|
||||
?? string.Empty,
|
||||
stopCompletely: d.CombatAttackOperations.PrepareAttackRequest,
|
||||
sendUntargeted: spellId =>
|
||||
late.Session.CurrentSession?.SendCastUntargetedSpell(spellId),
|
||||
sendTargeted: (target, spellId) =>
|
||||
late.Session.CurrentSession?.SendCastTargetedSpell(target, spellId),
|
||||
displayMessage:
|
||||
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
|
||||
incrementBusy: itemInteraction.IncrementBusyCount,
|
||||
canSend: () => late.Session.IsInWorld);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
|
||||
|
||||
uint MagicSkillLevel(MagicSchool school)
|
||||
{
|
||||
static uint SkillId(MagicSchool value) => value switch
|
||||
|
|
@ -717,7 +719,6 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
vitals,
|
||||
chat,
|
||||
characterSheet,
|
||||
magic,
|
||||
screenshots);
|
||||
}
|
||||
catch (Exception failure)
|
||||
|
|
@ -805,15 +806,12 @@ internal sealed class InteractionRetainedUiCompositionPhase
|
|||
_dependencies.SelectionCameraFactory(late.SelectionViewPlane));
|
||||
Fault(InteractionRetainedUiCompositionPoint.LateBindingsCreated);
|
||||
|
||||
var attackLease = scope.Acquire(
|
||||
"combat attack controller",
|
||||
() => _factory.CreateCombatAttack(_dependencies),
|
||||
_factory.Release);
|
||||
Fault(InteractionRetainedUiCompositionPoint.CombatAttackCreated);
|
||||
var targetLease = scope.Acquire(
|
||||
"combat target controller",
|
||||
() => _factory.CreateCombatTarget(_dependencies, late.Selection),
|
||||
_factory.Release);
|
||||
IDisposable combatTargetBinding = _factory.BindCombatTarget(
|
||||
_dependencies,
|
||||
late.Selection);
|
||||
late.AdoptLateOwnerBinding(
|
||||
"combat-target operations",
|
||||
combatTargetBinding);
|
||||
Fault(InteractionRetainedUiCompositionPoint.CombatTargetCreated);
|
||||
var externalLease = scope.Acquire(
|
||||
"external container lifecycle",
|
||||
|
|
@ -827,6 +825,14 @@ internal sealed class InteractionRetainedUiCompositionPhase
|
|||
() => _factory.CreateItemInteraction(_dependencies, late),
|
||||
_factory.Release);
|
||||
Fault(InteractionRetainedUiCompositionPoint.ItemInteractionCreated);
|
||||
var magicLease = scope.Acquire(
|
||||
"magic runtime",
|
||||
() => _factory.CreateMagicRuntime(
|
||||
_dependencies,
|
||||
late,
|
||||
itemLease.Resource),
|
||||
_factory.Release);
|
||||
Fault(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
|
||||
|
||||
var uiLease = scope.Own(
|
||||
"retained UI runtime lease",
|
||||
|
|
@ -839,8 +845,9 @@ internal sealed class InteractionRetainedUiCompositionPhase
|
|||
_dependencies,
|
||||
late,
|
||||
_retainedUiLease,
|
||||
attackLease.Resource,
|
||||
_dependencies.Actions.CombatAttack,
|
||||
itemLease.Resource,
|
||||
magicLease.Resource,
|
||||
Fault);
|
||||
}
|
||||
else
|
||||
|
|
@ -849,18 +856,17 @@ internal sealed class InteractionRetainedUiCompositionPhase
|
|||
}
|
||||
|
||||
var result = new InteractionRetainedUiResult(
|
||||
attackLease.Resource,
|
||||
targetLease.Resource,
|
||||
_dependencies.Actions.CombatAttack,
|
||||
externalLease.Resource,
|
||||
itemLease.Resource,
|
||||
magicLease.Resource,
|
||||
retainedUi,
|
||||
late);
|
||||
_publication.PublishInteractionRetainedUi(result);
|
||||
lateLease.Transfer();
|
||||
attackLease.Transfer();
|
||||
targetLease.Transfer();
|
||||
externalLease.Transfer();
|
||||
itemLease.Transfer();
|
||||
magicLease.Transfer();
|
||||
uiLease.Transfer();
|
||||
Fault(InteractionRetainedUiCompositionPoint.ResultPublished);
|
||||
scope.Complete();
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ internal sealed record SessionPlayerDependencies(
|
|||
WorldSceneDebugState WorldSceneDebugState,
|
||||
RuntimeDiagnosticCommandSlot RuntimeDiagnosticCommands,
|
||||
LiveCombatModeCommandSlot CombatModeCommands,
|
||||
RuntimeCombatModeOperationsSlot CombatModeOperations,
|
||||
HostQuiescenceGate HostQuiescence,
|
||||
PhysicsEngine PhysicsEngine,
|
||||
PhysicsDataCache PhysicsDataCache,
|
||||
|
|
@ -514,7 +515,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PhysicsEngine,
|
||||
content.Dats,
|
||||
content.AnimationLoader,
|
||||
interaction.CombatTarget,
|
||||
d.Actions.CombatTarget,
|
||||
d.WorldOrigin,
|
||||
d.TeleportSink,
|
||||
d.PlayerController,
|
||||
|
|
@ -804,7 +805,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
interaction.RetainedUi?.Runtime,
|
||||
vitals,
|
||||
interaction.RetainedUi?.CharacterSheet,
|
||||
interaction.RetainedUi?.Magic,
|
||||
interaction.Magic,
|
||||
live.PaperdollPresenter),
|
||||
new LiveSessionInteractionRuntime(
|
||||
d.Settings,
|
||||
|
|
@ -850,14 +851,18 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
Action<string>? debugToast = d.SettingsDevTools.DevTools is { } devOwner
|
||||
? message => devOwner.Debug.AddToast(message)
|
||||
: null;
|
||||
var combatCommand = new LiveCombatModeCommandController(
|
||||
var combatModeOperations = new LiveCombatModeOperations(
|
||||
new LiveSessionCombatModeAuthority(sessionHost),
|
||||
new LocalPlayerCombatEquipmentSource(
|
||||
d.EntityObjects.Objects,
|
||||
d.PlayerIdentity),
|
||||
d.Actions.Combat,
|
||||
new ItemInteractionCombatModeIntentSink(
|
||||
interaction.ItemInteraction),
|
||||
interaction.ItemInteraction));
|
||||
bindings.Adopt(
|
||||
"runtime combat-mode operations",
|
||||
d.CombatModeOperations.BindOwned(combatModeOperations));
|
||||
var combatCommand = new RuntimeCombatModeCommandAdapter(
|
||||
d.Actions.CombatMode,
|
||||
d.Log,
|
||||
debugToast,
|
||||
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au));
|
||||
|
|
@ -879,8 +884,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
worldReveal,
|
||||
d.UpdateClock,
|
||||
live.SelectionInteractions,
|
||||
gameplayInput,
|
||||
combatCommand);
|
||||
gameplayInput);
|
||||
bindings.Adopt("current game runtime adapter", gameRuntime);
|
||||
bindings.Adopt(
|
||||
"retained-UI game runtime commands",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
|
@ -13,18 +14,55 @@ internal interface ICombatInputFrameController
|
|||
|
||||
internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController
|
||||
{
|
||||
private readonly CombatAttackController _owner;
|
||||
private readonly RuntimeCombatAttackState _owner;
|
||||
|
||||
public CombatAttackInputFrameAdapter(CombatAttackController owner) =>
|
||||
public CombatAttackInputFrameAdapter(RuntimeCombatAttackState owner) =>
|
||||
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
|
||||
public void Tick() => _owner.Tick();
|
||||
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) =>
|
||||
_owner.HandleMovementInput(action, activation);
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press
|
||||
|| action is not (
|
||||
InputAction.MovementForward
|
||||
or InputAction.MovementBackup
|
||||
or InputAction.MovementRunLock
|
||||
or InputAction.MovementJump))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation) =>
|
||||
_owner.HandleInputAction(action, activation);
|
||||
_owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
RuntimeInputActivation.Press));
|
||||
}
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
RuntimeCombatAttackCommand? command = action switch
|
||||
{
|
||||
InputAction.CombatLowAttack =>
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
InputAction.CombatMediumAttack =>
|
||||
RuntimeCombatAttackCommand.MediumAttack,
|
||||
InputAction.CombatHighAttack =>
|
||||
RuntimeCombatAttackCommand.HighAttack,
|
||||
InputAction.CombatDecreaseAttackPower =>
|
||||
RuntimeCombatAttackCommand.DecreasePower,
|
||||
InputAction.CombatIncreaseAttackPower =>
|
||||
RuntimeCombatAttackCommand.IncreasePower,
|
||||
_ => null,
|
||||
};
|
||||
if (command is null)
|
||||
return false;
|
||||
|
||||
return _owner.HandleCommand(new RuntimeCombatAttackInput(
|
||||
command.Value,
|
||||
activation == ActivationType.Press
|
||||
? RuntimeInputActivation.Press
|
||||
: RuntimeInputActivation.Release));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ internal sealed record LiveSessionInteractionRuntime(
|
|||
PlayerModeController PlayerMode,
|
||||
PlayerModeAutoEntry PlayerModeAutoEntry,
|
||||
ItemInteractionController ItemInteraction,
|
||||
CombatAttackController CombatAttack,
|
||||
RuntimeCombatAttackState CombatAttack,
|
||||
SelectionInteractionController SelectionInteractions);
|
||||
|
||||
internal sealed record LiveSessionWorldRuntime(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -48,7 +49,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly PhysicsEngine _physicsEngine;
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly IAnimationLoader _animLoader;
|
||||
private readonly CombatTargetController? _combatTargetController;
|
||||
private readonly RuntimeCombatTargetState? _combatTargetController;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink
|
||||
_localPlayerTeleport;
|
||||
|
|
@ -90,7 +91,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
PhysicsEngine physicsEngine,
|
||||
IDatReaderWriter dats,
|
||||
IAnimationLoader animLoader,
|
||||
CombatTargetController? combatTargetController,
|
||||
RuntimeCombatTargetState? combatTargetController,
|
||||
LiveWorldOriginState origin,
|
||||
AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
|
||||
ILocalPlayerControllerSource playerControllerSource,
|
||||
|
|
|
|||
|
|
@ -373,10 +373,15 @@ public sealed class GameWindow :
|
|||
private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new();
|
||||
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
|
||||
_combatAttackOperations = new();
|
||||
private readonly AcDream.App.Combat.RuntimeCombatTargetOperationsSlot
|
||||
_combatTargetOperations = new();
|
||||
private readonly AcDream.App.Combat.RuntimeCombatModeOperationsSlot
|
||||
_combatModeOperations = new();
|
||||
private readonly AcDream.App.Spells.RuntimeSpellCastOperationsSlot
|
||||
_spellCastOperations = new();
|
||||
private readonly AcDream.App.Combat.CombatFeedbackSlot
|
||||
_combatFeedback = new();
|
||||
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
|
||||
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
||||
private RuntimeCombatAttackState? _combatAttackController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
|
||||
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
|
||||
|
|
@ -561,8 +566,14 @@ public sealed class GameWindow :
|
|||
{
|
||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
|
||||
_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);
|
||||
_runtimeCharacter = new RuntimeCharacterState();
|
||||
_runtimeActions = new RuntimeActionState(
|
||||
_runtimeInventory.Transactions,
|
||||
_runtimeCharacter.Spellbook,
|
||||
_combatAttackOperations,
|
||||
_combatTargetOperations,
|
||||
_combatModeOperations,
|
||||
_spellCastOperations);
|
||||
var alphaScratchBudgets =
|
||||
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
|
||||
_options.ResidencyBudgets.AlphaScratchBytes);
|
||||
|
|
@ -905,7 +916,6 @@ public sealed class GameWindow :
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (_combatAttackController is not null
|
||||
|| _combatTargetController is not null
|
||||
|| _externalContainerLifecycle is not null
|
||||
|| _itemInteractionController is not null
|
||||
|| _interactionUiLateBindings is not null
|
||||
|
|
@ -921,10 +931,10 @@ public sealed class GameWindow :
|
|||
}
|
||||
|
||||
_combatAttackController = result.CombatAttack;
|
||||
_combatTargetController = result.CombatTarget;
|
||||
_externalContainerLifecycle = result.ExternalContainerLifecycle;
|
||||
_itemInteractionController = result.ItemInteraction;
|
||||
_interactionUiLateBindings = result.LateBindings;
|
||||
_magicRuntime = result.Magic;
|
||||
if (result.RetainedUi is { } retained)
|
||||
{
|
||||
_uiHost = retained.Host;
|
||||
|
|
@ -932,7 +942,6 @@ public sealed class GameWindow :
|
|||
_vitalsVm ??= retained.Vitals;
|
||||
_retailChatVm = retained.Chat;
|
||||
_characterSheetProvider = retained.CharacterSheet;
|
||||
_magicRuntime = retained.Magic;
|
||||
_frameScreenshots = retained.Screenshots;
|
||||
}
|
||||
}
|
||||
|
|
@ -1256,6 +1265,8 @@ public sealed class GameWindow :
|
|||
_runtimeSettings,
|
||||
_runtimeActions,
|
||||
_combatAttackOperations,
|
||||
_combatTargetOperations,
|
||||
_spellCastOperations,
|
||||
_runtimeInventory,
|
||||
contentEffectsAudio.MagicCatalog,
|
||||
_runtimeCharacter,
|
||||
|
|
@ -1358,6 +1369,7 @@ public sealed class GameWindow :
|
|||
_worldSceneDebugState,
|
||||
_runtimeDiagnosticCommands,
|
||||
_liveCombatModeCommands,
|
||||
_combatModeOperations,
|
||||
_hostQuiescence,
|
||||
_physicsEngine,
|
||||
_physicsDataCache,
|
||||
|
|
@ -1564,8 +1576,7 @@ public sealed class GameWindow :
|
|||
new LiveShutdownRoots(
|
||||
_cameraPointerInput,
|
||||
_retailUiLease,
|
||||
_combatTargetController,
|
||||
_combatAttackController,
|
||||
_magicRuntime,
|
||||
_itemInteractionController,
|
||||
_externalContainerLifecycle,
|
||||
_streamer,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using AcDream.App.Rendering.Scene;
|
|||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.World;
|
||||
|
|
@ -76,8 +77,7 @@ internal sealed record FrameShutdownRoots(
|
|||
internal sealed record LiveShutdownRoots(
|
||||
CameraPointerInputController? CameraPointer,
|
||||
RetailUiRuntimeLease RetailUi,
|
||||
CombatTargetController? CombatTarget,
|
||||
CombatAttackController? CombatAttack,
|
||||
MagicRuntime? Magic,
|
||||
ItemInteractionController? ItemInteraction,
|
||||
ExternalContainerLifecycleController? ExternalContainers,
|
||||
LandblockStreamer? Streamer,
|
||||
|
|
@ -377,8 +377,7 @@ internal static class GameWindowShutdownManifest
|
|||
Hard("interaction/UI late bindings", () => frame.InteractionBindings?.Dispose()),
|
||||
Hard("mouse capture", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement()),
|
||||
Hard("retail UI", () => DisposeRetailUi(live.RetailUi)),
|
||||
Hard("combat target", () => live.CombatTarget?.Dispose()),
|
||||
Hard("combat attack", () => live.CombatAttack?.Dispose()),
|
||||
Hard("magic runtime", () => live.Magic?.Dispose()),
|
||||
Hard("item interaction", () => live.ItemInteraction?.Dispose()),
|
||||
Hard("external containers", () => live.ExternalContainers?.Dispose()),
|
||||
Hard("streamer", () => live.Streamer?.Dispose()),
|
||||
|
|
@ -393,12 +392,12 @@ internal static class GameWindowShutdownManifest
|
|||
]),
|
||||
new ResourceShutdownStage("runtime entity/object stream",
|
||||
[
|
||||
Hard(
|
||||
"runtime character state",
|
||||
live.Character.Dispose),
|
||||
Hard(
|
||||
"runtime action state",
|
||||
live.Actions.Dispose),
|
||||
Hard(
|
||||
"runtime character state",
|
||||
live.Character.Dispose),
|
||||
Hard(
|
||||
"runtime inventory state",
|
||||
live.Inventory.Dispose),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Net;
|
||||
|
|
@ -43,8 +42,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
WorldRevealCoordinator worldReveal,
|
||||
IGameRuntimeClock clock,
|
||||
SelectionInteractionController selection,
|
||||
GameplayInputFrameController gameplayInput,
|
||||
ILiveCombatModeCommand combat)
|
||||
GameplayInputFrameController gameplayInput)
|
||||
{
|
||||
_view = new CurrentGameRuntimeViewAdapter(
|
||||
session,
|
||||
|
|
@ -75,7 +73,6 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
actions,
|
||||
selection,
|
||||
gameplayInput,
|
||||
combat,
|
||||
_events);
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +92,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
public IRuntimeSessionCommands Session => _commands;
|
||||
public IRuntimeSelectionCommands Selection => _commands;
|
||||
public IRuntimeCombatCommands Combat => _commands;
|
||||
public IRuntimeMagicCommands Magic => _commands;
|
||||
public IRuntimeMovementCommands MovementCommands => _commands;
|
||||
IRuntimeMovementCommands IGameRuntimeCommands.Movement => _commands;
|
||||
public IRuntimeChatCommands ChatCommands => _commands;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Net;
|
||||
|
|
@ -19,6 +18,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
: IRuntimeSessionCommands,
|
||||
IRuntimeSelectionCommands,
|
||||
IRuntimeCombatCommands,
|
||||
IRuntimeMagicCommands,
|
||||
IRuntimeMovementCommands,
|
||||
IRuntimeChatCommands,
|
||||
IRuntimePortalCommands,
|
||||
|
|
@ -36,7 +36,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
private readonly RuntimeActionState _actions;
|
||||
private readonly SelectionInteractionController _selection;
|
||||
private readonly GameplayInputFrameController _gameplayInput;
|
||||
private readonly ILiveCombatModeCommand _combat;
|
||||
private readonly ICurrentGameRuntimeEventSink _events;
|
||||
|
||||
public CurrentGameRuntimeCommandAdapter(
|
||||
|
|
@ -49,7 +48,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
RuntimeActionState actions,
|
||||
SelectionInteractionController selection,
|
||||
GameplayInputFrameController gameplayInput,
|
||||
ILiveCombatModeCommand combat,
|
||||
ICurrentGameRuntimeEventSink events)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
|
|
@ -63,7 +61,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_gameplayInput = gameplayInput
|
||||
?? throw new ArgumentNullException(nameof(gameplayInput));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
}
|
||||
|
||||
|
|
@ -178,8 +175,16 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
RuntimeCommandStatus status;
|
||||
if (command == RuntimeCombatCommand.ToggleMode)
|
||||
{
|
||||
_combat.Toggle();
|
||||
status = RuntimeCommandStatus.Accepted;
|
||||
RuntimeCombatModeRequestResult result =
|
||||
_actions.CombatMode.Toggle();
|
||||
status = result.Status switch
|
||||
{
|
||||
RuntimeCombatModeRequestStatus.Sent =>
|
||||
RuntimeCommandStatus.Accepted,
|
||||
RuntimeCombatModeRequestStatus.Inactive =>
|
||||
RuntimeCommandStatus.Inactive,
|
||||
_ => RuntimeCommandStatus.Rejected,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -190,6 +195,49 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
return Result(status);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult ExecuteAttack(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeCombatAttackInput command)
|
||||
{
|
||||
RuntimeCommandStatus gate = Validate(
|
||||
expectedGeneration,
|
||||
requireWorld: true);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
|
||||
RuntimeCommandStatus status = _actions.CombatAttack.HandleCommand(
|
||||
command)
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Unsupported;
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Combat,
|
||||
operation: 0x100 + (int)command.Command,
|
||||
status);
|
||||
return Result(status);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeMagicCommand command)
|
||||
{
|
||||
RuntimeCommandStatus gate = Validate(
|
||||
expectedGeneration,
|
||||
requireWorld: true);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
|
||||
CastRequestResult cast = _actions.SpellCast.Cast(command.SpellId);
|
||||
RuntimeCommandStatus status = cast == CastRequestResult.Sent
|
||||
? RuntimeCommandStatus.Accepted
|
||||
: RuntimeCommandStatus.Rejected;
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Magic,
|
||||
operation: (int)cast,
|
||||
status,
|
||||
command.SpellId);
|
||||
return Result(status, command.SpellId);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
RuntimeMovementCommand command)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
|
@ -158,25 +159,29 @@ public sealed class MagicCatalog
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// App-layer owner for the live retail magic request path. The server owns
|
||||
/// turning, motion, fizzle, mana/component consumption, effects, and results.
|
||||
/// App content/policy projection for Runtime's live retail magic request
|
||||
/// owner. The server owns turning, motion, fizzle, mana/component consumption,
|
||||
/// effects, and results.
|
||||
/// </summary>
|
||||
public sealed class MagicRuntime
|
||||
public sealed class MagicRuntime : IDisposable
|
||||
{
|
||||
private readonly SpellComponentRequirementService _requirements;
|
||||
private IDisposable? _castOperationsBinding;
|
||||
|
||||
private MagicRuntime(
|
||||
MagicCatalog catalog,
|
||||
SpellCastingController casting,
|
||||
SpellComponentRequirementService requirements)
|
||||
RuntimeSpellCastState casting,
|
||||
SpellComponentRequirementService requirements,
|
||||
IDisposable castOperationsBinding)
|
||||
{
|
||||
Catalog = catalog;
|
||||
Casting = casting;
|
||||
_requirements = requirements;
|
||||
_castOperationsBinding = castOperationsBinding;
|
||||
}
|
||||
|
||||
public MagicCatalog Catalog { get; }
|
||||
public SpellCastingController Casting { get; }
|
||||
public RuntimeSpellCastState Casting { get; }
|
||||
|
||||
public IReadOnlyList<SpellExamineComponent> GetExamineComponents(uint spellId)
|
||||
{
|
||||
|
|
@ -200,11 +205,11 @@ public sealed class MagicRuntime
|
|||
return result;
|
||||
}
|
||||
|
||||
public static MagicRuntime Create(
|
||||
internal static MagicRuntime Create(
|
||||
MagicCatalog catalog,
|
||||
Spellbook spellbook,
|
||||
RuntimeSpellCastState casting,
|
||||
RuntimeSpellCastOperationsSlot operationsSlot,
|
||||
ClientObjectTable objects,
|
||||
Func<uint?> selectedObject,
|
||||
Func<uint> localPlayerId,
|
||||
Func<string> accountName,
|
||||
Action stopCompletely,
|
||||
|
|
@ -215,39 +220,29 @@ public sealed class MagicRuntime
|
|||
Func<bool> canSend)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(casting);
|
||||
ArgumentNullException.ThrowIfNull(operationsSlot);
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
ArgumentNullException.ThrowIfNull(displayMessage);
|
||||
|
||||
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
|
||||
objects, localPlayerId, accountName);
|
||||
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
|
||||
{
|
||||
if (objects.Get(target) is not { } targetObject)
|
||||
return false;
|
||||
|
||||
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
|
||||
localPlayerId(), targetObject, spell);
|
||||
if (showMessage && !result.Allowed && result.Message is { } message)
|
||||
displayMessage(message);
|
||||
return result.Allowed;
|
||||
}
|
||||
|
||||
var casting = new SpellCastingController(
|
||||
spellbook,
|
||||
selectedObject,
|
||||
var operations = new LiveSpellCastOperations(
|
||||
requirements,
|
||||
objects,
|
||||
localPlayerId,
|
||||
stopCompletely,
|
||||
sendUntargeted,
|
||||
sendTargeted,
|
||||
displayMessage,
|
||||
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
|
||||
hasRequiredComponents: requirements.HasRequiredComponents,
|
||||
incrementBusy: incrementBusy,
|
||||
canSend: canSend,
|
||||
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
|
||||
return new MagicRuntime(catalog, casting, requirements);
|
||||
incrementBusy,
|
||||
canSend);
|
||||
IDisposable binding = operationsSlot.BindOwned(operations);
|
||||
return new MagicRuntime(catalog, casting, requirements, binding);
|
||||
}
|
||||
|
||||
public void Reset() => Casting.Reset();
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _castOperationsBinding, null)?.Dispose();
|
||||
}
|
||||
|
|
|
|||
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.Combat;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
|
|
@ -93,7 +93,7 @@ public static class FixtureProvider
|
|||
case CombatUiController.LayoutId:
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var attacks = new CombatAttackController(
|
||||
var attacks = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -46,7 +46,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
private readonly UiButton _autoTarget;
|
||||
private readonly UiButton _keepInView;
|
||||
private readonly CombatState _combat;
|
||||
private readonly CombatAttackController _attacks;
|
||||
private readonly RuntimeCombatAttackState _attacks;
|
||||
private readonly Func<GameplaySettings> _gameplay;
|
||||
private readonly Action<GameplaySettings> _setGameplay;
|
||||
private readonly Action<bool> _setWindowVisible;
|
||||
|
|
@ -64,7 +64,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
UiButton autoTarget,
|
||||
UiButton keepInView,
|
||||
CombatState combat,
|
||||
CombatAttackController attacks,
|
||||
RuntimeCombatAttackState attacks,
|
||||
Func<GameplaySettings> gameplay,
|
||||
Action<GameplaySettings> setGameplay,
|
||||
CombatUiLabels labels,
|
||||
|
|
@ -125,7 +125,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
public static CombatUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
CombatState combat,
|
||||
CombatAttackController attacks,
|
||||
RuntimeCombatAttackState attacks,
|
||||
Func<GameplaySettings> gameplay,
|
||||
Action<GameplaySettings> setGameplay,
|
||||
CombatUiLabels labels,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -13,7 +14,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <summary>
|
||||
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
|
||||
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
|
||||
/// request through <see cref="SpellCastingController"/>.
|
||||
/// request through <see cref="RuntimeSpellCastState"/>.
|
||||
/// </summary>
|
||||
public sealed class SpellcastingUiController : IRetainedPanelController
|
||||
{
|
||||
|
|
@ -37,7 +38,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
];
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly SpellCastingController _casting;
|
||||
private readonly RuntimeSpellCastState _casting;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
|
|
@ -69,7 +70,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
private SpellcastingUiController(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
SpellCastingController casting,
|
||||
RuntimeSpellCastState casting,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
|
|
@ -158,7 +159,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
public static SpellcastingUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
SpellCastingController casting,
|
||||
RuntimeSpellCastState casting,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Content;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
|
@ -44,13 +45,13 @@ public sealed record RadarRuntimeBindings(
|
|||
|
||||
public sealed record CombatRuntimeBindings(
|
||||
CombatState State,
|
||||
CombatAttackController Attacks,
|
||||
RuntimeCombatAttackState Attacks,
|
||||
Func<GameplaySettings> Gameplay,
|
||||
Action<GameplaySettings> SetGameplay);
|
||||
|
||||
public sealed record MagicRuntimeBindings(
|
||||
Spellbook Spellbook,
|
||||
SpellCastingController Casting,
|
||||
RuntimeSpellCastState Casting,
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@ using AcDream.Runtime.Gameplay;
|
|||
|
||||
namespace AcDream.Runtime;
|
||||
|
||||
public readonly record struct RuntimeCombatAttackSnapshot(
|
||||
long Revision,
|
||||
AttackHeight RequestedHeight,
|
||||
float DesiredPower,
|
||||
float PowerBarLevel,
|
||||
bool BuildInProgress,
|
||||
bool RequestInProgress,
|
||||
float RequestedPower);
|
||||
|
||||
public readonly record struct RuntimeSpellCastSnapshot(
|
||||
long Revision,
|
||||
uint LastRequestedSpellId,
|
||||
uint LastRequestedTargetId);
|
||||
|
||||
public readonly record struct RuntimeActionSnapshot(
|
||||
long SelectionRevision,
|
||||
uint SelectedObjectId,
|
||||
|
|
@ -14,7 +28,9 @@ public readonly record struct RuntimeActionSnapshot(
|
|||
long InteractionRevision,
|
||||
InteractionModeKind InteractionMode,
|
||||
uint InteractionSourceObjectId,
|
||||
RuntimeInteractionTransactionSnapshot InteractionTransactions);
|
||||
RuntimeInteractionTransactionSnapshot InteractionTransactions,
|
||||
RuntimeCombatAttackSnapshot CombatAttack = default,
|
||||
RuntimeSpellCastSnapshot Magic = default);
|
||||
|
||||
public interface IRuntimeActionView
|
||||
{
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@ public interface IRuntimeCombatCommands
|
|||
RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
RuntimeCombatCommand command);
|
||||
|
||||
RuntimeCommandResult ExecuteAttack(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in Gameplay.RuntimeCombatAttackInput command);
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeMagicCommand(uint SpellId);
|
||||
|
||||
public interface IRuntimeMagicCommands
|
||||
{
|
||||
RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeMagicCommand command);
|
||||
}
|
||||
|
||||
public interface IRuntimeMovementCommands
|
||||
|
|
@ -251,6 +264,8 @@ public interface IGameRuntimeCommands
|
|||
|
||||
IRuntimeCombatCommands Combat { get; }
|
||||
|
||||
IRuntimeMagicCommands Magic { get; }
|
||||
|
||||
IRuntimeMovementCommands Movement { get; }
|
||||
|
||||
IRuntimeChatCommands Chat { get; }
|
||||
|
|
|
|||
|
|
@ -7,16 +7,17 @@ public readonly record struct RuntimeEventStamp(
|
|||
|
||||
public enum RuntimeCommandDomain
|
||||
{
|
||||
Session,
|
||||
Selection,
|
||||
Combat,
|
||||
Movement,
|
||||
Chat,
|
||||
Portal,
|
||||
InventoryState,
|
||||
Spellbook,
|
||||
Character,
|
||||
Social,
|
||||
Session = 0,
|
||||
Selection = 1,
|
||||
Combat = 2,
|
||||
Movement = 3,
|
||||
Chat = 4,
|
||||
Portal = 5,
|
||||
InventoryState = 6,
|
||||
Spellbook = 7,
|
||||
Character = 8,
|
||||
Social = 9,
|
||||
Magic = 10,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeLifecycleDelta(
|
||||
|
|
@ -176,7 +177,17 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
|
|||
$"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken};" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken}:" +
|
||||
$"{checkpoint.Actions.CombatAttack.Revision}:" +
|
||||
$"{(int)checkpoint.Actions.CombatAttack.RequestedHeight}:" +
|
||||
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.DesiredPower):X8}:" +
|
||||
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.PowerBarLevel):X8}:" +
|
||||
$"{checkpoint.Actions.CombatAttack.BuildInProgress}:" +
|
||||
$"{checkpoint.Actions.CombatAttack.RequestInProgress}:" +
|
||||
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.RequestedPower):X8}:" +
|
||||
$"{checkpoint.Actions.Magic.Revision}:" +
|
||||
$"{checkpoint.Actions.Magic.LastRequestedSpellId:X8}:" +
|
||||
$"{checkpoint.Actions.Magic.LastRequestedTargetId:X8};" +
|
||||
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
|
||||
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
|
|
@ -8,6 +9,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
|
|||
bool IsDisposed,
|
||||
bool InternalSubscriptionsAttached,
|
||||
bool InteractionTransactionsDisposed,
|
||||
bool CombatAttackDisposed,
|
||||
bool CombatTargetDisposed,
|
||||
bool SpellCastReset,
|
||||
uint SelectedObjectId,
|
||||
uint PreviousObjectId,
|
||||
uint PreviousValidObjectId,
|
||||
|
|
@ -23,6 +27,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
|
|||
IsDisposed
|
||||
&& !InternalSubscriptionsAttached
|
||||
&& InteractionTransactionsDisposed
|
||||
&& CombatAttackDisposed
|
||||
&& CombatTargetDisposed
|
||||
&& SpellCastReset
|
||||
&& SelectedObjectId == 0u
|
||||
&& PreviousObjectId == 0u
|
||||
&& PreviousValidObjectId == 0u
|
||||
|
|
@ -33,9 +40,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for selection, combat
|
||||
/// notifications/mode, and temporary interaction target mode. Graphical and
|
||||
/// no-window hosts borrow these exact instances.
|
||||
/// Canonical presentation-independent owner for selection, interaction
|
||||
/// transactions, combat mode/attack/target intent, and spell-cast intent.
|
||||
/// Graphical and no-window hosts borrow these exact instances.
|
||||
/// </summary>
|
||||
public sealed class RuntimeActionState : IDisposable
|
||||
{
|
||||
|
|
@ -44,21 +51,52 @@ public sealed class RuntimeActionState : IDisposable
|
|||
private long _selectionRevision;
|
||||
private long _combatRevision;
|
||||
private long _interactionRevision;
|
||||
private long _combatIntentRevision;
|
||||
private long _magicIntentRevision;
|
||||
|
||||
public RuntimeActionState(InventoryTransactionState inventoryTransactions)
|
||||
public RuntimeActionState(
|
||||
InventoryTransactionState inventoryTransactions,
|
||||
Spellbook spellbook,
|
||||
IRuntimeCombatAttackOperations combatAttackOperations,
|
||||
IRuntimeCombatTargetOperations combatTargetOperations,
|
||||
IRuntimeCombatModeOperations combatModeOperations,
|
||||
IRuntimeSpellCastOperations spellCastOperations,
|
||||
Func<double>? now = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventoryTransactions);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(combatAttackOperations);
|
||||
ArgumentNullException.ThrowIfNull(combatTargetOperations);
|
||||
ArgumentNullException.ThrowIfNull(combatModeOperations);
|
||||
ArgumentNullException.ThrowIfNull(spellCastOperations);
|
||||
Selection = new SelectionState();
|
||||
Combat = new CombatState();
|
||||
Interaction = new InteractionState();
|
||||
Transactions = new RuntimeInteractionTransactionState(
|
||||
inventoryTransactions);
|
||||
CombatAttack = new RuntimeCombatAttackState(
|
||||
Combat,
|
||||
combatAttackOperations,
|
||||
now);
|
||||
CombatTarget = new RuntimeCombatTargetState(
|
||||
Combat,
|
||||
Selection,
|
||||
combatTargetOperations);
|
||||
CombatMode = new RuntimeCombatModeState(
|
||||
Combat,
|
||||
combatModeOperations);
|
||||
SpellCast = new RuntimeSpellCastState(
|
||||
spellbook,
|
||||
Selection,
|
||||
spellCastOperations);
|
||||
View = new ActionView(this);
|
||||
|
||||
Selection.Changed += OnSelectionChanged;
|
||||
Combat.CombatModeChanged += OnCombatModeChanged;
|
||||
Combat.HealthChanged += OnHealthChanged;
|
||||
Interaction.Changed += OnInteractionChanged;
|
||||
CombatAttack.StateChanged += OnCombatAttackChanged;
|
||||
SpellCast.StateChanged += OnSpellCastChanged;
|
||||
_internalSubscriptionsAttached = true;
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +104,10 @@ public sealed class RuntimeActionState : IDisposable
|
|||
public CombatState Combat { get; }
|
||||
public InteractionState Interaction { get; }
|
||||
public RuntimeInteractionTransactionState Transactions { get; }
|
||||
public RuntimeCombatAttackState CombatAttack { get; }
|
||||
public RuntimeCombatTargetState CombatTarget { get; }
|
||||
public RuntimeCombatModeState CombatMode { get; }
|
||||
public RuntimeSpellCastState SpellCast { get; }
|
||||
public IRuntimeActionView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
|
|
@ -73,6 +115,10 @@ public sealed class RuntimeActionState : IDisposable
|
|||
_disposed,
|
||||
_internalSubscriptionsAttached,
|
||||
Transactions.IsDisposed,
|
||||
CombatAttack.IsDisposed,
|
||||
CombatTarget.IsDisposed,
|
||||
SpellCast.LastRequestedSpellId is null
|
||||
&& SpellCast.LastRequestedTargetId is null,
|
||||
Selection.SelectedObjectId ?? 0u,
|
||||
Selection.PreviousObjectId ?? 0u,
|
||||
Selection.PreviousValidObjectId ?? 0u,
|
||||
|
|
@ -95,6 +141,8 @@ public sealed class RuntimeActionState : IDisposable
|
|||
List<Exception>? failures = null;
|
||||
Try(Transactions.ResetSession, ref failures);
|
||||
Try(Interaction.ResetSession, ref failures);
|
||||
Try(SpellCast.Reset, ref failures);
|
||||
Try(CombatAttack.ResetSession, ref failures);
|
||||
Try(() => Selection.Reset(), ref failures);
|
||||
Try(Combat.Clear, ref failures);
|
||||
if (failures is not null)
|
||||
|
|
@ -115,6 +163,8 @@ public sealed class RuntimeActionState : IDisposable
|
|||
{
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
Try(Interaction.ResetSession, ref failures);
|
||||
Try(SpellCast.Reset, ref failures);
|
||||
Try(CombatAttack.ResetSession, ref failures);
|
||||
Try(() => Selection.Reset(), ref failures);
|
||||
Try(Combat.Clear, ref failures);
|
||||
}
|
||||
|
|
@ -124,6 +174,10 @@ public sealed class RuntimeActionState : IDisposable
|
|||
Combat.CombatModeChanged -= OnCombatModeChanged;
|
||||
Combat.HealthChanged -= OnHealthChanged;
|
||||
Interaction.Changed -= OnInteractionChanged;
|
||||
CombatAttack.StateChanged -= OnCombatAttackChanged;
|
||||
SpellCast.StateChanged -= OnSpellCastChanged;
|
||||
Try(CombatAttack.Dispose, ref failures);
|
||||
Try(CombatTarget.Dispose, ref failures);
|
||||
_internalSubscriptionsAttached = false;
|
||||
_disposed = true;
|
||||
}
|
||||
|
|
@ -148,6 +202,12 @@ public sealed class RuntimeActionState : IDisposable
|
|||
private void OnInteractionChanged(InteractionModeTransition _) =>
|
||||
Interlocked.Increment(ref _interactionRevision);
|
||||
|
||||
private void OnCombatAttackChanged() =>
|
||||
Interlocked.Increment(ref _combatIntentRevision);
|
||||
|
||||
private void OnSpellCastChanged() =>
|
||||
Interlocked.Increment(ref _magicIntentRevision);
|
||||
|
||||
private static void Try(Action action, ref List<Exception>? failures)
|
||||
{
|
||||
try
|
||||
|
|
@ -174,7 +234,19 @@ public sealed class RuntimeActionState : IDisposable
|
|||
Interlocked.Read(ref owner._interactionRevision),
|
||||
owner.Interaction.Current.Kind,
|
||||
owner.Interaction.Current.SourceObjectId,
|
||||
owner.Transactions.CaptureOwnership());
|
||||
owner.Transactions.CaptureOwnership(),
|
||||
new RuntimeCombatAttackSnapshot(
|
||||
Interlocked.Read(ref owner._combatIntentRevision),
|
||||
owner.CombatAttack.RequestedHeight,
|
||||
owner.CombatAttack.DesiredPower,
|
||||
owner.CombatAttack.PowerBarLevel,
|
||||
owner.CombatAttack.BuildInProgress,
|
||||
owner.CombatAttack.AttackRequestInProgress,
|
||||
owner.CombatAttack.RequestedAttackPower),
|
||||
new RuntimeSpellCastSnapshot(
|
||||
Interlocked.Read(ref owner._magicIntentRevision),
|
||||
owner.SpellCast.LastRequestedSpellId ?? 0u,
|
||||
owner.SpellCast.LastRequestedTargetId ?? 0u));
|
||||
|
||||
public bool TryGetHealth(uint objectId, out float healthPercent)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,29 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
internal interface ICombatAttackOperations
|
||||
public enum RuntimeInputActivation
|
||||
{
|
||||
Press,
|
||||
Release,
|
||||
}
|
||||
|
||||
public enum RuntimeCombatAttackCommand
|
||||
{
|
||||
LowAttack,
|
||||
MediumAttack,
|
||||
HighAttack,
|
||||
DecreasePower,
|
||||
IncreasePower,
|
||||
AbortForMovement,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand Command,
|
||||
RuntimeInputActivation Activation);
|
||||
|
||||
public interface IRuntimeCombatAttackOperations
|
||||
{
|
||||
bool CanStartAttack();
|
||||
void PrepareAttackRequest();
|
||||
|
|
@ -15,7 +34,8 @@ internal interface ICombatAttackOperations
|
|||
bool AutoRepeatAttack { get; }
|
||||
}
|
||||
|
||||
internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
|
||||
internal sealed class DelegateRuntimeCombatAttackOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
|
|
@ -25,7 +45,7 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
|
|||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
|
||||
public DelegateCombatAttackOperations(
|
||||
public DelegateRuntimeCombatAttackOperations(
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? prepareAttackRequest,
|
||||
|
|
@ -56,8 +76,8 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
|
|||
|
||||
/// <summary>
|
||||
/// Owns retail's basic-combat attack request and power-bar state machine.
|
||||
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
|
||||
/// and <see cref="ReleaseAttack"/>; the wire send remains a host callback.
|
||||
/// Graphical controls and no-window hosts both enter through typed Runtime
|
||||
/// commands; the wire send remains a host callback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail references: <c>ClientCombatSystem::Begin</c> (0x0056A460),
|
||||
|
|
@ -68,7 +88,7 @@ internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
|
|||
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0), and
|
||||
/// <c>AbortAutomaticAttack</c> (0x0056AE90).
|
||||
/// </remarks>
|
||||
public sealed class CombatAttackController : IDisposable
|
||||
public sealed class RuntimeCombatAttackState : IDisposable
|
||||
{
|
||||
public const double AttackPowerUpSeconds = CombatInputPlanner.AttackPowerUpSeconds;
|
||||
public const double DualWieldPowerUpSeconds = CombatInputPlanner.DualWieldPowerUpSeconds;
|
||||
|
|
@ -76,7 +96,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
public const float DesiredPowerStep = 1f / 6f;
|
||||
|
||||
private readonly CombatState _combat;
|
||||
private readonly ICombatAttackOperations _operations;
|
||||
private readonly IRuntimeCombatAttackOperations _operations;
|
||||
private readonly Func<double> _now;
|
||||
|
||||
private bool _buildInProgress;
|
||||
|
|
@ -91,7 +111,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
private float _latestPowerBarLevel;
|
||||
private bool _disposed;
|
||||
|
||||
public CombatAttackController(
|
||||
public RuntimeCombatAttackState(
|
||||
CombatState combat,
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
|
|
@ -103,7 +123,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
Func<double>? now = null)
|
||||
: this(
|
||||
combat,
|
||||
new DelegateCombatAttackOperations(
|
||||
new DelegateRuntimeCombatAttackOperations(
|
||||
canStartAttack,
|
||||
sendAttack,
|
||||
prepareAttackRequest,
|
||||
|
|
@ -115,9 +135,9 @@ public sealed class CombatAttackController : IDisposable
|
|||
{
|
||||
}
|
||||
|
||||
internal CombatAttackController(
|
||||
public RuntimeCombatAttackState(
|
||||
CombatState combat,
|
||||
ICombatAttackOperations operations,
|
||||
IRuntimeCombatAttackOperations operations,
|
||||
Func<double>? now = null)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
|
|
@ -134,6 +154,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
public bool AttackRequestInProgress => _attackRequestInProgress;
|
||||
public float RequestedAttackPower => _requestedAttackPower;
|
||||
public bool BuildInProgress => _buildInProgress;
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <summary>The level retail publishes to the embedded combat meter.</summary>
|
||||
public float PowerBarLevel => _buildInProgress
|
||||
|
|
@ -142,38 +163,43 @@ public sealed class CombatAttackController : IDisposable
|
|||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
public bool HandleCommand(in RuntimeCombatAttackInput command)
|
||||
{
|
||||
AttackHeight? height = action switch
|
||||
AttackHeight? height = command.Command switch
|
||||
{
|
||||
InputAction.CombatLowAttack => AttackHeight.Low,
|
||||
InputAction.CombatMediumAttack => AttackHeight.Medium,
|
||||
InputAction.CombatHighAttack => AttackHeight.High,
|
||||
RuntimeCombatAttackCommand.LowAttack => AttackHeight.Low,
|
||||
RuntimeCombatAttackCommand.MediumAttack => AttackHeight.Medium,
|
||||
RuntimeCombatAttackCommand.HighAttack => AttackHeight.High,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (height is not null)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
if (command.Activation == RuntimeInputActivation.Press)
|
||||
PressAttack(height.Value);
|
||||
else if (activation == ActivationType.Release)
|
||||
else if (command.Activation == RuntimeInputActivation.Release)
|
||||
ReleaseAttack();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activation != ActivationType.Press)
|
||||
if (command.Activation != RuntimeInputActivation.Press)
|
||||
return false;
|
||||
|
||||
if (action == InputAction.CombatDecreaseAttackPower)
|
||||
if (command.Command == RuntimeCombatAttackCommand.DecreasePower)
|
||||
{
|
||||
StepDesiredPower(-1);
|
||||
return true;
|
||||
}
|
||||
if (action == InputAction.CombatIncreaseAttackPower)
|
||||
if (command.Command == RuntimeCombatAttackCommand.IncreasePower)
|
||||
{
|
||||
StepDesiredPower(1);
|
||||
return true;
|
||||
}
|
||||
if (command.Command == RuntimeCombatAttackCommand.AbortForMovement)
|
||||
{
|
||||
AbortAutomaticAttack();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -183,20 +209,6 @@ public sealed class CombatAttackController : IDisposable
|
|||
/// call <c>HandleNewForwardMovement</c>. Jump has its own equivalent cancel
|
||||
/// in <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
|
||||
/// </summary>
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press)
|
||||
return;
|
||||
|
||||
if (action is InputAction.MovementForward
|
||||
or InputAction.MovementBackup
|
||||
or InputAction.MovementRunLock
|
||||
or InputAction.MovementJump)
|
||||
{
|
||||
AbortAutomaticAttack();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesiredPower(float power)
|
||||
{
|
||||
float value = Math.Clamp(power, 0f, 1f);
|
||||
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.Selection;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public interface IRuntimeCombatTargetOperations
|
||||
{
|
||||
bool AutoTarget { get; }
|
||||
uint? SelectClosestTarget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns combat-target lifecycle around the shared selection state.
|
||||
|
|
@ -14,25 +20,23 @@ namespace AcDream.App.Combat;
|
|||
/// makes the selected combat object unavailable and enters that same notice
|
||||
/// path.
|
||||
/// </remarks>
|
||||
public sealed class CombatTargetController : IDisposable
|
||||
public sealed class RuntimeCombatTargetState : IDisposable
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Func<bool> _autoTarget;
|
||||
private readonly Func<uint?> _selectClosestTarget;
|
||||
private readonly IRuntimeCombatTargetOperations _operations;
|
||||
private bool _disposed;
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
public CombatTargetController(
|
||||
public RuntimeCombatTargetState(
|
||||
CombatState combat,
|
||||
SelectionState selection,
|
||||
Func<bool> autoTarget,
|
||||
Func<uint?> selectClosestTarget)
|
||||
IRuntimeCombatTargetOperations operations)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_autoTarget = autoTarget ?? throw new ArgumentNullException(nameof(autoTarget));
|
||||
_selectClosestTarget = selectClosestTarget
|
||||
?? throw new ArgumentNullException(nameof(selectClosestTarget));
|
||||
_operations = operations
|
||||
?? throw new ArgumentNullException(nameof(operations));
|
||||
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
}
|
||||
|
|
@ -64,10 +68,10 @@ public sealed class CombatTargetController : IDisposable
|
|||
{
|
||||
if (transition.SelectedObjectId is not null
|
||||
|| transition.Reason == SelectionChangeReason.SessionReset
|
||||
|| !_autoTarget()
|
||||
|| !_operations.AutoTarget
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
return;
|
||||
|
||||
_selectClosestTarget();
|
||||
_operations.SelectClosestTarget();
|
||||
}
|
||||
}
|
||||
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.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
|
@ -152,7 +153,7 @@ public sealed class LiveCombatAttackOperationsTests
|
|||
public void Show(string message) => Messages.Add(message);
|
||||
}
|
||||
|
||||
private sealed class FakeOperations : ICombatAttackOperations
|
||||
private sealed class FakeOperations : IRuntimeCombatAttackOperations
|
||||
{
|
||||
public bool CanStartValue { get; init; } = true;
|
||||
public bool CanStartAttack() => CanStartValue;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
|
|
@ -148,11 +149,13 @@ public sealed class LiveCombatModeCommandControllerTests
|
|||
Authority = new FakeAuthority(Calls);
|
||||
Equipment = new FakeEquipment();
|
||||
Intent = new FakeIntent(Calls);
|
||||
Controller = new LiveCombatModeCommandController(
|
||||
var operations = new LiveCombatModeOperations(
|
||||
Authority,
|
||||
Equipment,
|
||||
Combat,
|
||||
Intent,
|
||||
Intent);
|
||||
Runtime = new RuntimeCombatModeState(Combat, operations);
|
||||
Controller = new RuntimeCombatModeCommandAdapter(
|
||||
Runtime,
|
||||
line => Calls.Add($"log:{line}"),
|
||||
line => Calls.Add($"toast:{line}"),
|
||||
line => Calls.Add($"system:{line}"));
|
||||
|
|
@ -163,7 +166,8 @@ public sealed class LiveCombatModeCommandControllerTests
|
|||
public FakeEquipment Equipment { get; }
|
||||
public CombatState Combat { get; } = new();
|
||||
public FakeIntent Intent { get; }
|
||||
public LiveCombatModeCommandController Controller { get; }
|
||||
public RuntimeCombatModeState Runtime { get; }
|
||||
public RuntimeCombatModeCommandAdapter Controller { get; }
|
||||
}
|
||||
|
||||
private sealed class FakeAuthority(List<string> calls)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.Composition;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
|
|
@ -17,7 +22,6 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiCompositionPoint.InputCaptureBound,
|
||||
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
|
||||
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
|
||||
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
||||
InteractionRetainedUiCompositionPoint.MouseInputWired,
|
||||
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
|
||||
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
|
||||
|
|
@ -37,14 +41,15 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Assert.Equal(
|
||||
[
|
||||
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
||||
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
||||
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
||||
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
||||
.. UiPoints,
|
||||
InteractionRetainedUiCompositionPoint.ResultPublished,
|
||||
], fixture.Points);
|
||||
Assert.NotNull(result.RetainedUi);
|
||||
Assert.NotNull(result.Magic);
|
||||
Assert.Empty(fixture.Factory.Releases);
|
||||
}
|
||||
|
||||
|
|
@ -56,13 +61,14 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiResult result = fixture.Compose();
|
||||
|
||||
Assert.Null(result.RetainedUi);
|
||||
Assert.NotNull(result.Magic);
|
||||
Assert.Equal(
|
||||
[
|
||||
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
||||
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
||||
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
||||
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
||||
InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
|
||||
InteractionRetainedUiCompositionPoint.ResultPublished,
|
||||
], fixture.Points);
|
||||
|
|
@ -108,14 +114,12 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiCompositionPoint point)
|
||||
{
|
||||
var acquired = new List<string> { "late bindings" };
|
||||
if (point >= InteractionRetainedUiCompositionPoint.CombatAttackCreated)
|
||||
acquired.Add("combat attack");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.CombatTargetCreated)
|
||||
acquired.Add("combat target");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
|
||||
acquired.Add("external container");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
|
||||
acquired.Add("item interaction");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.MagicRuntimeCreated)
|
||||
acquired.Add("magic runtime");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
|
||||
acquired.Add("retained UI lease");
|
||||
acquired.Reverse();
|
||||
|
|
@ -132,10 +136,9 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"retained UI lease",
|
||||
"magic runtime",
|
||||
"item interaction",
|
||||
"external container",
|
||||
"combat target",
|
||||
"combat attack",
|
||||
"late bindings",
|
||||
], fixture.Factory.Releases);
|
||||
}
|
||||
|
|
@ -172,6 +175,13 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Publication = new Publication(publicationFailure);
|
||||
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
|
||||
with { RetailUi = retailUi };
|
||||
var actions = new RuntimeActionState(
|
||||
new InventoryTransactionState(new ClientObjectTable()),
|
||||
new Spellbook(),
|
||||
new NoopCombatOperations(),
|
||||
new NoopCombatTargetOperations(),
|
||||
new NoopCombatModeOperations(),
|
||||
new NoopSpellOperations());
|
||||
Dependencies = new InteractionRetainedUiDependencies(
|
||||
Options: options,
|
||||
Gl: null!,
|
||||
|
|
@ -186,8 +196,10 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
RetainedInputCapture: null!,
|
||||
InputDispatcher: null,
|
||||
Settings: null!,
|
||||
Actions: null!,
|
||||
CombatAttackOperations: null!,
|
||||
Actions: actions,
|
||||
CombatAttackOperations: new NoopCombatOperations(),
|
||||
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),
|
||||
SpellCastOperations: new RuntimeSpellCastOperationsSlot(),
|
||||
Inventory: null!,
|
||||
MagicCatalog: null!,
|
||||
Character: null!,
|
||||
|
|
@ -233,14 +245,10 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
public List<string> Releases { get; } = [];
|
||||
public int RetainedUiCalls { get; private set; }
|
||||
|
||||
public CombatAttackController CreateCombatAttack(
|
||||
InteractionRetainedUiDependencies dependencies) =>
|
||||
Resource<CombatAttackController>("combat attack");
|
||||
|
||||
public CombatTargetController CreateCombatTarget(
|
||||
public IDisposable BindCombatTarget(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
DeferredSelectionUiAuthority selection) =>
|
||||
Resource<CombatTargetController>("combat target");
|
||||
new NoopDisposable();
|
||||
|
||||
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
|
|
@ -252,12 +260,19 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionUiLateBindings lateBindings) =>
|
||||
Resource<ItemInteractionController>("item interaction");
|
||||
|
||||
public MagicRuntime CreateMagicRuntime(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings,
|
||||
ItemInteractionController itemInteraction) =>
|
||||
Resource<MagicRuntime>("magic runtime");
|
||||
|
||||
public RetainedUiComposition CreateRetainedUi(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings,
|
||||
RetailUiRuntimeLease lease,
|
||||
CombatAttackController combatAttack,
|
||||
RuntimeCombatAttackState combatAttack,
|
||||
ItemInteractionController itemInteraction,
|
||||
MagicRuntime magic,
|
||||
Action<InteractionRetainedUiCompositionPoint> checkpoint)
|
||||
{
|
||||
RetainedUiCalls++;
|
||||
|
|
@ -270,7 +285,6 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(),
|
||||
Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(),
|
||||
Stub<CharacterSheetProvider>(),
|
||||
Stub<AcDream.App.Spells.MagicRuntime>(),
|
||||
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)
|
||||
: IGameWindowInteractionRetainedUiPublication
|
||||
{
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ public sealed class InteractionUiRuntimeSourcesTests
|
|||
public IRuntimeSessionCommands Session => null!;
|
||||
public IRuntimeSelectionCommands Selection => null!;
|
||||
public IRuntimeCombatCommands Combat => null!;
|
||||
public IRuntimeMagicCommands Magic => null!;
|
||||
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
|
||||
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
|
||||
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
|
@ -184,6 +185,13 @@ public sealed class GameplayInputCommandControllerTests
|
|||
RuntimeCommandStatus.Accepted,
|
||||
expectedGeneration);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult ExecuteAttack(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeCombatAttackInput command) =>
|
||||
new(
|
||||
RuntimeCommandStatus.Unsupported,
|
||||
expectedGeneration);
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeView : IGameRuntimeView
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.App.Input;
|
|||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Runtime;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.Update;
|
||||
|
|
@ -16,6 +17,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
|
@ -97,7 +99,12 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.True(portal.Accepted);
|
||||
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
|
||||
Assert.True(harness.MovementInput.AutoRunActive);
|
||||
Assert.Equal(1, harness.Combat.ToggleCount);
|
||||
Assert.Equal(
|
||||
[AcDream.Core.Combat.CombatMode.Melee],
|
||||
harness.CombatMode.Sent);
|
||||
Assert.Equal(
|
||||
AcDream.Core.Combat.CombatMode.Melee,
|
||||
harness.Actions.Combat.CurrentMode);
|
||||
Assert.Contains(
|
||||
harness.Commands.Published,
|
||||
static command => command is SendChatCmd
|
||||
|
|
@ -178,10 +185,89 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.Equal(active, stopped.RetiredGeneration);
|
||||
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
|
||||
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
|
||||
Assert.Equal(0, harness.Combat.ToggleCount);
|
||||
Assert.Empty(harness.CombatMode.Sent);
|
||||
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalAndDirectCombatMagicCommands_ReachTheExactSameOwners()
|
||||
{
|
||||
using var graphical = new Harness();
|
||||
using var direct = new Harness();
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
graphical.Runtime.Session.Start(graphical.Runtime.Generation).Status);
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
direct.Runtime.Session.Start(direct.Runtime.Generation).Status);
|
||||
var directTrace = new RuntimeTraceRecorder();
|
||||
using IDisposable directTraceSubscription =
|
||||
direct.Runtime.Subscribe(directTrace);
|
||||
|
||||
var graphicalAttack = new RecordingAttackOperations();
|
||||
var directAttack = new RecordingAttackOperations();
|
||||
var graphicalSpell = new RecordingSpellOperations();
|
||||
var directSpell = new RecordingSpellOperations();
|
||||
using IDisposable graphicalAttackBinding =
|
||||
graphical.CombatAttackOperations.BindOwned(graphicalAttack);
|
||||
using IDisposable directAttackBinding =
|
||||
direct.CombatAttackOperations.BindOwned(directAttack);
|
||||
using IDisposable graphicalSpellBinding =
|
||||
graphical.SpellCastOperations.BindOwned(graphicalSpell);
|
||||
using IDisposable directSpellBinding =
|
||||
direct.SpellCastOperations.BindOwned(directSpell);
|
||||
|
||||
PrepareCombatAndMagic(graphical);
|
||||
PrepareCombatAndMagic(direct);
|
||||
|
||||
var graphicalInput =
|
||||
new CombatAttackInputFrameAdapter(graphical.Actions.CombatAttack);
|
||||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Press));
|
||||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Release));
|
||||
Assert.Equal(
|
||||
CastRequestResult.Sent,
|
||||
graphical.Actions.SpellCast.Cast(1u));
|
||||
|
||||
RuntimeGenerationToken generation = direct.Runtime.Generation;
|
||||
Assert.True(direct.Runtime.Combat.ExecuteAttack(
|
||||
generation,
|
||||
new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Press)).Accepted);
|
||||
Assert.True(direct.Runtime.Combat.ExecuteAttack(
|
||||
generation,
|
||||
new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Release)).Accepted);
|
||||
Assert.True(direct.Runtime.Magic.Execute(
|
||||
generation,
|
||||
new RuntimeMagicCommand(1u)).Accepted);
|
||||
|
||||
Assert.Equal(graphicalAttack.Trace, directAttack.Trace);
|
||||
Assert.Equal(graphicalSpell.Trace, directSpell.Trace);
|
||||
Assert.Equal(
|
||||
graphical.Actions.View.Snapshot.CombatAttack,
|
||||
direct.Actions.View.Snapshot.CombatAttack);
|
||||
Assert.Equal(
|
||||
graphical.Actions.View.Snapshot.Magic,
|
||||
direct.Actions.View.Snapshot.Magic);
|
||||
Assert.Equal(
|
||||
[
|
||||
RuntimeCommandDomain.Combat,
|
||||
RuntimeCommandDomain.Combat,
|
||||
RuntimeCommandDomain.Magic,
|
||||
],
|
||||
directTrace.Entries
|
||||
.Where(static entry => entry.Kind == RuntimeTraceKind.Command)
|
||||
.Select(static entry =>
|
||||
(RuntimeCommandDomain)(entry.Code >> 16))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
|
||||
{
|
||||
|
|
@ -668,6 +754,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
|
||||
private readonly ItemInteractionController _items;
|
||||
private readonly LiveSessionController _session;
|
||||
private readonly IDisposable _combatModeBinding;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
|
|
@ -681,14 +768,26 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
EntityObjects);
|
||||
Objects = EntityObjects.Objects;
|
||||
Communication = new RuntimeCommunicationState();
|
||||
Actions = new RuntimeActionState(InventoryState.Transactions);
|
||||
CombatAttackOperations = new CombatAttackOperationsSlot();
|
||||
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
|
||||
SpellCastOperations = new RuntimeSpellCastOperationsSlot();
|
||||
Actions = new RuntimeActionState(
|
||||
InventoryState.Transactions,
|
||||
Character.Spellbook,
|
||||
CombatAttackOperations,
|
||||
new RuntimeCombatTargetOperationsSlot(),
|
||||
CombatModeOperations,
|
||||
SpellCastOperations,
|
||||
now: static () => 0d);
|
||||
CombatMode = new RecordingCombatModeOperations();
|
||||
_combatModeBinding =
|
||||
CombatModeOperations.BindOwned(CombatMode);
|
||||
MovementInput = new DispatcherMovementInputSource();
|
||||
GameplayInput = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
MovementInput,
|
||||
mouseLook: null,
|
||||
new NoopCombatInput());
|
||||
Combat = new RecordingCombatCommand();
|
||||
Clock = new UpdateFrameClock();
|
||||
WorldReveal = new WorldRevealCoordinator(
|
||||
static (_, _) => true,
|
||||
|
|
@ -737,8 +836,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
WorldReveal,
|
||||
Clock,
|
||||
selectionController,
|
||||
GameplayInput,
|
||||
Combat);
|
||||
GameplayInput);
|
||||
}
|
||||
|
||||
public RuntimeOptions Options { get; }
|
||||
|
|
@ -750,11 +848,14 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
public ClientObjectTable Objects { get; }
|
||||
public RuntimeCommunicationState Communication { get; }
|
||||
public ChatLog Chat => Communication.Chat;
|
||||
public CombatAttackOperationsSlot CombatAttackOperations { get; }
|
||||
public RuntimeCombatModeOperationsSlot CombatModeOperations { get; }
|
||||
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
|
||||
public RuntimeActionState Actions { get; }
|
||||
public SelectionState Selection => Actions.Selection;
|
||||
public DispatcherMovementInputSource MovementInput { get; }
|
||||
public GameplayInputFrameController GameplayInput { get; }
|
||||
public RecordingCombatCommand Combat { get; }
|
||||
public RecordingCombatModeOperations CombatMode { get; }
|
||||
public UpdateFrameClock Clock { get; }
|
||||
public WorldRevealCoordinator WorldReveal { get; }
|
||||
public RecordingCommandRouting Commands { get; }
|
||||
|
|
@ -769,12 +870,72 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Communication.Dispose();
|
||||
_session.Dispose();
|
||||
_items.Dispose();
|
||||
_combatModeBinding.Dispose();
|
||||
Actions.Dispose();
|
||||
InventoryState.Dispose();
|
||||
Entities.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrepareCombatAndMagic(Harness harness)
|
||||
{
|
||||
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
|
||||
harness.Actions.CombatAttack.SetDesiredPower(0f);
|
||||
const string csv =
|
||||
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]\n"
|
||||
+ "1,Runtime Test Spell,0x0,true,0x0";
|
||||
harness.Character.InstallSpellMetadata(
|
||||
SpellTable.LoadFromReader(new System.IO.StringReader(csv)));
|
||||
harness.Character.Spellbook.OnSpellLearned(1u);
|
||||
}
|
||||
|
||||
private sealed class RecordingAttackOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
public List<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(
|
||||
LiveSessionController controller,
|
||||
RecordingCommandRouting commands,
|
||||
|
|
@ -1031,10 +1192,15 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
public void Dispose() => _active = false;
|
||||
}
|
||||
|
||||
internal sealed class RecordingCombatCommand : ILiveCombatModeCommand
|
||||
internal sealed class RecordingCombatModeOperations
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
public int ToggleCount { get; private set; }
|
||||
public void Toggle() => ToggleCount++;
|
||||
public List<AcDream.Core.Combat.CombatMode> Sent { get; } = [];
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class RuntimeActionOwnershipTests
|
|||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);",
|
||||
"_runtimeActions = new RuntimeActionState(",
|
||||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
|
|
@ -61,6 +61,18 @@ public sealed class RuntimeActionOwnershipTests
|
|||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeInteractionTransactionState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatAttackState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatTargetState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatModeState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeSpellCastState\s*\("));
|
||||
Assert.Equal(
|
||||
1,
|
||||
Regex.Matches(
|
||||
|
|
@ -104,6 +116,11 @@ public sealed class RuntimeActionOwnershipTests
|
|||
Assert.Contains("d.Actions.Transactions,", ui, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_dependencies.Actions.CombatAttack",
|
||||
ui,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.SpellCast", ui, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal);
|
||||
Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal);
|
||||
|
|
|
|||
|
|
@ -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.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -33,7 +34,7 @@ public sealed class CombatUiControllerTests
|
|||
Assert.False(high.Selected);
|
||||
Assert.True(medium.Selected);
|
||||
Assert.False(low.Selected);
|
||||
Assert.Equal(CombatAttackController.InitialDesiredPower, power.ScalarPosition);
|
||||
Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, power.ScalarPosition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -123,7 +124,7 @@ public sealed class CombatUiControllerTests
|
|||
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
|
||||
"High", "Medium", "Low");
|
||||
|
||||
private static CombatAttackController CreateAttacks(
|
||||
private static RuntimeCombatAttackState CreateAttacks(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
List<(AttackHeight Height, float Power)> sent)
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ public class SelectedObjectControllerTests
|
|||
h.StackMap[Replacement] = 1u;
|
||||
|
||||
h.Selection.Select(Dead, SelectionChangeSource.World);
|
||||
// CombatTargetController is registered before the retained toolbar in
|
||||
// RuntimeCombatTargetState is registered before the retained toolbar in
|
||||
// production. Its clear handler selects a replacement reentrantly.
|
||||
h.Selection.Changed += transition =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.App.UI.Layout;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -330,14 +331,17 @@ public sealed class SpellcastingUiControllerTests
|
|||
SelectionState? selection = null,
|
||||
Action<uint>? examineSpell = null)
|
||||
{
|
||||
var casting = new SpellCastingController(
|
||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||
SelectionState selectionState = selection ?? new SelectionState();
|
||||
var casting = new RuntimeSpellCastState(
|
||||
spellbook,
|
||||
selectionState,
|
||||
new NoopSpellCastOperations());
|
||||
return SpellcastingUiController.Bind(
|
||||
layout, spellbook, casting, objects, () => 1u,
|
||||
spellId => spellId,
|
||||
item => item.ObjectId,
|
||||
useItem,
|
||||
selection ?? new SelectionState(),
|
||||
selectionState,
|
||||
(tab, position, spellId) =>
|
||||
{
|
||||
spellbook.SetFavorite(tab, position, spellId);
|
||||
|
|
@ -349,6 +353,22 @@ public sealed class SpellcastingUiControllerTests
|
|||
examineSpell);
|
||||
}
|
||||
|
||||
private sealed class NoopSpellCastOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId => 1u;
|
||||
public bool CanSend => true;
|
||||
public bool HasRequiredComponents(uint spellId) => true;
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => true;
|
||||
public void StopCompletely() { }
|
||||
public void SendUntargeted(uint spellId) { }
|
||||
public void SendTargeted(uint targetId, uint spellId) { }
|
||||
public void DisplayMessage(string message) { }
|
||||
public void IncrementBusy() { }
|
||||
}
|
||||
|
||||
private static void ApplyAnchors(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
|
|
|
|||
|
|
@ -597,11 +597,11 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
field => field.FieldType == typeof(Silk.NET.Input.IMouse));
|
||||
|
||||
FieldInfo combatOperations = Assert.Single(
|
||||
typeof(AcDream.App.Combat.CombatAttackController).GetFields(
|
||||
typeof(AcDream.Runtime.Gameplay.RuntimeCombatAttackState).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => field.Name == "_operations");
|
||||
Assert.Equal(
|
||||
typeof(AcDream.App.Combat.ICombatAttackOperations),
|
||||
typeof(AcDream.Runtime.Gameplay.IRuntimeCombatAttackOperations),
|
||||
combatOperations.FieldType);
|
||||
Assert.DoesNotContain(
|
||||
typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields(
|
||||
|
|
|
|||
|
|
@ -192,7 +192,8 @@ public sealed class GameRuntimeContractTests
|
|||
Assert.Contains("social=9:10:11", entry.Text);
|
||||
Assert.Contains(
|
||||
"actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" +
|
||||
"00000000:00000000:00000000:0:0",
|
||||
"00000000:00000000:00000000:0:0:0:0:00000000:00000000:" +
|
||||
"False:False:00000000:0:00000000:00000000",
|
||||
entry.Text);
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +209,10 @@ public sealed class GameRuntimeContractTests
|
|||
typeof(RuntimeMovementSkillState),
|
||||
typeof(RuntimeActionState),
|
||||
typeof(InteractionState),
|
||||
typeof(RuntimeCombatAttackState),
|
||||
typeof(RuntimeCombatTargetState),
|
||||
typeof(RuntimeCombatModeState),
|
||||
typeof(RuntimeSpellCastState),
|
||||
];
|
||||
|
||||
foreach (Type owner in owners)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public sealed class RuntimeActionStateTests
|
|||
public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
|
||||
{
|
||||
using var inventory = NewInventoryTransactions();
|
||||
using var actions = new RuntimeActionState(inventory);
|
||||
using var actions = RuntimeActionTestFactory.Create(inventory);
|
||||
|
||||
actions.Selection.Select(
|
||||
0x50000001u,
|
||||
|
|
@ -41,7 +41,7 @@ public sealed class RuntimeActionStateTests
|
|||
public void ResetSessionConvergesEveryChildAfterObserverFailures()
|
||||
{
|
||||
using var inventory = NewInventoryTransactions();
|
||||
var actions = new RuntimeActionState(inventory);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory);
|
||||
actions.Selection.Select(
|
||||
0x50000001u,
|
||||
SelectionChangeSource.World);
|
||||
|
|
@ -73,7 +73,7 @@ public sealed class RuntimeActionStateTests
|
|||
public void DisposeIsTerminalAndConvergedAfterObserverFailures()
|
||||
{
|
||||
using var inventory = NewInventoryTransactions();
|
||||
var actions = new RuntimeActionState(inventory);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory);
|
||||
actions.Selection.Select(
|
||||
0x50000001u,
|
||||
SelectionChangeSource.World);
|
||||
|
|
@ -100,8 +100,8 @@ public sealed class RuntimeActionStateTests
|
|||
{
|
||||
using var firstInventory = NewInventoryTransactions();
|
||||
using var secondInventory = NewInventoryTransactions();
|
||||
using var first = new RuntimeActionState(firstInventory);
|
||||
using var second = new RuntimeActionState(secondInventory);
|
||||
using var first = RuntimeActionTestFactory.Create(firstInventory);
|
||||
using var second = RuntimeActionTestFactory.Create(secondInventory);
|
||||
|
||||
first.Selection.Select(
|
||||
0x50000001u,
|
||||
|
|
@ -131,7 +131,19 @@ public sealed class RuntimeActionStateTests
|
|||
OutboundCount: 0,
|
||||
HasPendingPickup: false,
|
||||
PendingPickupToken: 0u,
|
||||
DispatchFailureCount: 0)),
|
||||
DispatchFailureCount: 0),
|
||||
new RuntimeCombatAttackSnapshot(
|
||||
Revision: 0,
|
||||
RequestedHeight: AttackHeight.Medium,
|
||||
DesiredPower: 0.5f,
|
||||
PowerBarLevel: 0f,
|
||||
BuildInProgress: false,
|
||||
RequestInProgress: false,
|
||||
RequestedPower: 0f),
|
||||
new RuntimeSpellCastSnapshot(
|
||||
Revision: 0,
|
||||
LastRequestedSpellId: 0u,
|
||||
LastRequestedTargetId: 0u)),
|
||||
second.View.Snapshot);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.UI.Abstractions.Input;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class CombatAttackControllerTests
|
||||
public sealed class RuntimeCombatAttackStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
|
||||
|
|
@ -76,11 +75,13 @@ public sealed class CombatAttackControllerTests
|
|||
using var controller = Create(combat, () => now, sent);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Press));
|
||||
Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Press)));
|
||||
now = 1.5d;
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Release));
|
||||
Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Release)));
|
||||
|
||||
Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
|
||||
}
|
||||
|
|
@ -108,7 +109,7 @@ public sealed class CombatAttackControllerTests
|
|||
double now = 0d;
|
||||
bool ready = false;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
() => true,
|
||||
(_, _) => true,
|
||||
|
|
@ -135,7 +136,7 @@ public sealed class CombatAttackControllerTests
|
|||
int cancels = 0;
|
||||
var sent = new List<(AttackHeight Height, float Power)>();
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (height, power) =>
|
||||
|
|
@ -153,8 +154,9 @@ public sealed class CombatAttackControllerTests
|
|||
controller.ReleaseAttack();
|
||||
Assert.Single(sent);
|
||||
|
||||
controller.HandleMovementInput(
|
||||
InputAction.MovementForward, ActivationType.Press);
|
||||
controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
RuntimeInputActivation.Press));
|
||||
combat.OnAttackDone(1u, 0u);
|
||||
|
||||
Assert.Equal(1, cancels);
|
||||
|
|
@ -168,7 +170,7 @@ public sealed class CombatAttackControllerTests
|
|||
{
|
||||
int cancels = 0;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
|
|
@ -184,8 +186,8 @@ public sealed class CombatAttackControllerTests
|
|||
{
|
||||
var events = new List<string>();
|
||||
var combat = new CombatState();
|
||||
CombatAttackController? controller = null;
|
||||
controller = new CombatAttackController(
|
||||
RuntimeCombatAttackState? controller = null;
|
||||
controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) =>
|
||||
|
|
@ -221,7 +223,7 @@ public sealed class CombatAttackControllerTests
|
|||
double now = 1d;
|
||||
int cancels = 0;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
|
|
@ -239,11 +241,11 @@ public sealed class CombatAttackControllerTests
|
|||
Assert.Equal(0f, controller.RequestedAttackPower);
|
||||
Assert.Equal(0f, controller.PowerBarLevel);
|
||||
Assert.Equal(AttackHeight.Medium, controller.RequestedHeight);
|
||||
Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower);
|
||||
Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, controller.DesiredPower);
|
||||
Assert.Equal(0, cancels);
|
||||
}
|
||||
|
||||
private static CombatAttackController Create(
|
||||
private static RuntimeCombatAttackState Create(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
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.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class CombatTargetControllerTests
|
||||
public sealed class RuntimeCombatTargetStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeadSelectedTarget_AutoTargetEnabled_SelectsClosestReplacement()
|
||||
|
|
@ -15,16 +15,15 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
autoTarget: () => true,
|
||||
selectClosestTarget: () =>
|
||||
new Operations(true, () =>
|
||||
{
|
||||
calls++;
|
||||
selection.Select(0x50000002u, SelectionChangeSource.System);
|
||||
return 0x50000002u;
|
||||
});
|
||||
}));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -40,8 +39,10 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => false, () => { calls++; return null; });
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(false, () => { calls++; return null; }));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -56,8 +57,10 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => true, () => { calls++; return null; });
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(true, () => { calls++; return null; }));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -72,8 +75,12 @@ public sealed class CombatTargetControllerTests
|
|||
combat.SetCombatMode(CombatMode.Missile);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(0x50000002u, SelectionChangeSource.World);
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => true, () => throw new InvalidOperationException());
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(
|
||||
true,
|
||||
() => throw new InvalidOperationException()));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -88,12 +95,23 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000002u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => true, () => { calls++; return null; });
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(true, () => { calls++; return null; }));
|
||||
|
||||
selection.Reset();
|
||||
|
||||
Assert.Equal(0, calls);
|
||||
Assert.Null(selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
private sealed class Operations(
|
||||
bool autoTarget,
|
||||
Func<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 character = new RuntimeCharacterState();
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var actions = new RuntimeActionState(inventory.Transactions);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||
inventory.Shortcuts.Changed += static () => { };
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
|
||||
|
|
@ -114,7 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
var inventory = new RuntimeInventoryState(entities);
|
||||
var character = new RuntimeCharacterState();
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var actions = new RuntimeActionState(inventory.Transactions);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||
|
||||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
|
|
|
|||
|
|
@ -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