refactor(runtime): own combat and magic intent

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

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

View file

@ -135,7 +135,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive D3DBLEND_ONE |
| AP-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` |

View file

@ -0,0 +1,189 @@
# Slice J5.3 — retail combat and magic intent ownership
## Scope
This note pins the retail ordering that must remain unchanged while the
presentation-independent combat and casting state moves from `AcDream.App` to
`AcDream.Runtime`. The move changes ownership and command types only. ACE
remains authoritative for combat mode settlement, attacks, casts, damage,
motion, component consumption, effects, and completion.
Primary oracle:
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
Cross-checks:
- the ACE/ACViewer request-layout cross-checks pinned in
`docs/research/2026-07-11-retail-combat-bar-pseudocode.md` and
`docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`;
- the ACE corpse/death-state cross-check pinned in
`docs/research/2026-07-12-death-and-auto-target-pseudocode.md`;
- existing conformance ports in
`src/AcDream.Core/Combat/CombatModel.cs` and
`src/AcDream.Core/Spells/RetailSpellTargetPolicy.cs`
## Named retail mechanisms
### `ClientCombatSystem::Begin @ 0x0056A460`
Pseudocode:
```text
combatMode = NonCombat
pendingCombatMode = Undefined
clear jump and power-bar timing
clear request, server-pending, queued-request, attack, and repeat state
requestedPower = 0
requestedHeight = Medium
desiredUiPower = 0.5
trackingTarget = false
advancedCombat = false
```
This is the session-reset state of the Runtime combat-intent owner.
### `ClientCombatSystem::StartAttackRequest @ 0x0056C040`
Pseudocode:
```text
target = GetAttackTarget()
if advanced combat or (target exists and ObjectIsAttackable(target)):
attackRequestInProgress = true
requestedAttackPower = 1.0
FinishJump()
MaybeStopCompletely()
currentBuildIsAutomatic = false
AttemptStartBuildingAttack()
else:
display the retail invalid-target notice
```
The request flags commit before the shared movement owner is asked to finish
jump/stop. The host operation preserves that exact boundary and may publish
the resulting movement packet before a later attack send.
### `StartPowerBarBuild @ 0x0056ADB0`,
`EndAttackRequest @ 0x0056C0E0`, and `UseTime @ 0x0056C1F0`
Pseudocode:
```text
StartPowerBarBuild:
buildInProgress = true
buildStartTime = current game time
publish level 0
latestPowerBarLevel = 0
EndAttackRequest:
if no request: return
requestInProgress = false
currentLevel = GetPowerBarLevel()
requestedPower = explicit power, advanced current level,
or min(desiredUiPower, currentLevel)
if server response pending:
remember one queued release
else if desired power reached or repeating:
ExecuteAttack()
UseTime:
while building, publish the current level
if the player is not ready, cancel/reset according to repeat state
after release, execute once the captured requested level is reached
```
### `AbortAutomaticAttack @ 0x0056AE90`
If a request, attack, server response, or repeat is active, retail sends
`Event_CancelAttack`, clears repeat, and hides the combat power build.
`ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0` invokes this before the
new forward movement enters the interpreter. Runtime therefore receives a
typed movement-abort command at that existing ordering boundary.
### `HandleAttackDoneEvent @ 0x0056C500`
Retail releases the attack busy reference (when held), clears server-pending
state, aborts repeat on authoritative error, starts the next automatic build
or attack only when repeat remains enabled, and finally drains the one queued
release. Runtime must never predict completion.
### `GetDefaultCombatMode @ 0x0056B310` and
`ToggleCombatMode @ 0x0056C8C0`
From non-combat, retail scans equipped contents in inventory-placement order:
missile equipment selects Missile, a caster in Held selects Magic, ordinary
melee/two-handed equipment selects Melee, and an incompatible Held item
rejects the transition with its item-name notice. With no weapon it selects
unarmed Melee. From any combat mode, Toggle selects NonCombat without running
the default scan.
The exact decision remains the existing Core port
`CombatInputPlanner.GetDefaultCombatModeDecision`; Runtime owns the command
state and App supplies ordered equipment plus transport/presentation sinks.
### `RecvNotice_SelectionChanged @ 0x0056BD80` and
`AutoTarget @ 0x0056BC80`
When selection becomes zero in Melee/Missile mode and Auto Target is enabled,
retail enters its next-target selection path. An authoritative Dead motion
makes the current target unavailable and reaches the same selection-change
path. Runtime owns the identity transition; App supplies the world query that
selects the closest retail-compatible hostile.
### `ClientMagicSystem::CastSpell @ 0x00568040`
Pseudocode:
```text
resolve the exact known spell metadata
if components are required:
resolve the appropriate formula
reject when a required component is unavailable
resolve self, untargeted, or selected target
validate the target against the spell target mask
FreeHandsAndCastSpell(spellId, targetId)
```
### `ClientMagicSystem::FreeHandsAndCastSpell @ 0x00566EF0`
Pseudocode:
```text
MaybeStopCompletely()
if targetId == 0:
Event_CastUntargetedSpell(spellId)
else:
Event_CastTargetedSpell(targetId, spellId)
IncrementBusyCount()
```
The cast owner therefore stops first, emits exactly one packet, and increments
the shared busy reference only after a successful send. It does not locally
turn the avatar: retail sends after `MaybeStopCompletely`, and the
authoritative server turn/cast motion supplies the visible facing. Send
failure clears the remembered request and cannot increment busy.
`ClientMagicSystem::OnEndCharacterSession @ 0x00566F50` clears selected and
targeting spell state, which maps to Runtime session reset.
## Ownership result
Runtime owns:
- attack build/request/repeat/queued-release state and time;
- typed attack commands and movement-abort intent;
- combat-mode command policy;
- authoritative target-death/auto-target identity transitions;
- cast validation intent and last requested spell/target;
- reset, teardown, revisions, and presentation-independent snapshots.
App retains:
- input-key and retained-widget mapping;
- ordered equipped-object and closest-hostile world queries;
- local movement preparation and outbound transport;
- target/spell compatibility data that depends on the live object table;
- DAT spell catalog/formulas/components/icons;
- messages, bars, animations, audio, particles, and camera presentation.
No new retail divergence is introduced by this ownership move.

View file

@ -1,6 +1,7 @@
using AcDream.App.Input;
using AcDream.App.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;

View file

@ -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);
}

View file

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

View file

@ -45,7 +45,9 @@ internal sealed record InteractionRetainedUiDependencies(
InputDispatcher? InputDispatcher,
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();

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

@ -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),

View file

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

View file

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

View file

@ -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();
}

View file

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

View file

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

View file

@ -1,9 +1,9 @@
using AcDream.App.Rendering;
using AcDream.App.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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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; }

View file

@ -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}")));
}

View file

@ -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)
{

View file

@ -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);

View file

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

View file

@ -2,7 +2,13 @@ using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.Core.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();
}
}

View file

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

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.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;

View file

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

View file

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

View file

@ -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!;

View file

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

View file

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

View file

@ -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);

View file

@ -1,119 +0,0 @@
using AcDream.App.Spells;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class SpellCastingControllerTests
{
[Fact]
public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
{
Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
uint target = 0;
var controller = MakeController(book, selected: 99, player: 42,
sendTargeted: (id, _) => target = id);
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
Assert.Equal(42u, target);
}
[Fact]
public void Cast_TargetedWithoutSelection_DoesNotSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
int sends = 0;
string message = "";
var controller = MakeController(book, selected: null, player: 42,
sendTargeted: (_, _) => sends++, display: value => message = value);
Assert.Equal(CastRequestResult.NoTarget, controller.Cast(1));
Assert.Equal(0, sends);
Assert.Contains("select", message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var order = new List<string>();
var controller = MakeController(book, selected: null, player: 42,
stop: () => order.Add("stop"),
sendUntargeted: _ => order.Add("send"),
incrementBusy: () => order.Add("busy"));
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
Assert.Equal(["stop", "send", "busy"], order);
}
[Fact]
public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int sends = 0, busy = 0;
var controller = MakeController(book, selected: null, player: 42,
sendUntargeted: _ => sends++, hasComponents: _ => false,
incrementBusy: () => busy++);
Assert.Equal(CastRequestResult.MissingComponents, controller.Cast(1));
Assert.Equal(0, sends);
Assert.Equal(0, busy);
}
[Fact]
public void Cast_Disconnected_DoesNotIncrementBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int busy = 0;
var controller = MakeController(book, selected: null, player: 42,
incrementBusy: () => busy++, canSend: () => false);
Assert.Equal(CastRequestResult.Unavailable, controller.Cast(1));
Assert.Equal(0, busy);
}
[Fact]
public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int increments = 0;
var controller = MakeController(book, selected: null, player: 42,
sendUntargeted: _ => throw new InvalidOperationException("wire"),
incrementBusy: () => increments++);
Assert.Throws<InvalidOperationException>(() => controller.Cast(1));
Assert.Equal(0, increments);
Assert.Null(controller.LastRequestedSpellId);
}
private static SpellCastingController MakeController(
Spellbook book,
uint? selected,
uint player,
Action? stop = null,
Action<uint>? sendUntargeted = null,
Action<uint, uint>? sendTargeted = null,
Action<string>? display = null,
Func<uint, bool>? hasComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null) => new(
book,
() => selected,
() => player,
stop ?? (() => { }),
sendUntargeted ?? (_ => { }),
sendTargeted ?? ((_, _) => { }),
display ?? (_ => { }),
hasRequiredComponents: hasComponents,
incrementBusy: incrementBusy,
canSend: canSend);
private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
{
const string header = "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
SpellTable table = SpellTable.LoadFromReader(new StringReader($"{header}\n{row}"));
var book = new Spellbook(table);
book.OnSpellLearned(1);
return book;
}
}

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.UI;
using AcDream.App.UI.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)

View file

@ -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 =>
{

View file

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

View file

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

View file

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

View file

@ -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);
}

View file

@ -0,0 +1,64 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
internal static class RuntimeActionTestFactory
{
public static RuntimeActionState Create(
InventoryTransactionState inventory,
Func<double>? now = null) =>
new(
inventory,
new Spellbook(),
new CombatOperations(),
new CombatTargetOperations(),
new CombatModeOperations(),
new SpellOperations(),
now);
private sealed class CombatOperations : IRuntimeCombatAttackOperations
{
public bool CanStartAttack() => false;
public void PrepareAttackRequest() { }
public bool SendAttack(AttackHeight height, float power) => false;
public void SendCancelAttack() { }
public bool IsDualWield => false;
public bool PlayerReadyForAttack => false;
public bool AutoRepeatAttack => false;
}
private sealed class CombatTargetOperations
: IRuntimeCombatTargetOperations
{
public bool AutoTarget => false;
public uint? SelectClosestTarget() => null;
}
private sealed class CombatModeOperations
: IRuntimeCombatModeOperations
{
public bool IsInWorld => false;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(CombatMode mode) { }
}
private sealed class SpellOperations : IRuntimeSpellCastOperations
{
public uint LocalPlayerId => 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
public void StopCompletely() { }
public void SendUntargeted(uint spellId) { }
public void SendTargeted(uint targetId, uint spellId) { }
public void DisplayMessage(string message) { }
public void IncrementBusy() { }
}
}

View file

@ -1,10 +1,9 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.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,

View file

@ -0,0 +1,121 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCombatModeStateTests
{
[Fact]
public void OutsideWorld_IsCompleteNoOp()
{
var combat = new CombatState();
var operations = new Operations { IsInWorld = false };
var state = new RuntimeCombatModeState(combat, operations);
RuntimeCombatModeRequestResult result = state.Toggle();
Assert.Equal(RuntimeCombatModeRequestStatus.Inactive, result.Status);
Assert.Empty(operations.Trace);
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
}
[Fact]
public void PeaceWithBow_OrdersIntentSendThenLocalState()
{
var combat = new CombatState();
var operations = new Operations();
operations.Equipment.Add(new ClientObject
{
ObjectId = 1u,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
Type = ItemType.MissileWeapon,
CombatUse = 2,
});
combat.CombatModeChanged += mode =>
operations.Trace.Add($"state:{mode}");
var state = new RuntimeCombatModeState(combat, operations);
RuntimeCombatModeRequestResult result = state.Toggle();
Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
Assert.Equal(CombatMode.Missile, result.Mode);
Assert.Equal(
["intent", "equipment", "send:Missile", "state:Missile"],
operations.Trace);
}
[Fact]
public void ActiveCombat_LeavesWithoutEquipmentQuery()
{
var combat = new CombatState();
combat.SetCombatMode(CombatMode.Magic);
var operations = new Operations();
var state = new RuntimeCombatModeState(combat, operations);
RuntimeCombatModeRequestResult result = state.Toggle();
Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
Assert.Equal(CombatMode.NonCombat, result.Mode);
Assert.DoesNotContain("equipment", operations.Trace);
}
[Fact]
public void IncompatibleHeldItem_RejectsAfterExplicitIntent()
{
var combat = new CombatState();
var operations = new Operations();
operations.Equipment.Add(new ClientObject
{
ObjectId = 1u,
Name = "Torch",
CurrentlyEquippedLocation = EquipMask.Held,
Type = ItemType.Misc,
});
var state = new RuntimeCombatModeState(combat, operations);
RuntimeCombatModeRequestResult result = state.Toggle();
Assert.Equal(RuntimeCombatModeRequestStatus.Rejected, result.Status);
Assert.Equal(
"You can't enter combat mode while wielding the Torch",
result.Notice);
Assert.Equal(["intent", "equipment"], operations.Trace);
}
[Fact]
public void TransportFailure_DoesNotPublishLocalMode()
{
var combat = new CombatState();
var operations = new Operations { ThrowOnSend = true };
var state = new RuntimeCombatModeState(combat, operations);
Assert.Throws<InvalidOperationException>(() => state.Toggle());
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
Assert.Equal(["intent", "equipment", "send:Melee"], operations.Trace);
}
private sealed class Operations : IRuntimeCombatModeOperations
{
public bool IsInWorld { get; init; } = true;
public bool ThrowOnSend { get; init; }
public List<ClientObject> Equipment { get; } = [];
public List<string> Trace { get; } = [];
public IReadOnlyList<ClientObject> GetOrderedEquipment()
{
Trace.Add("equipment");
return Equipment;
}
public void NotifyExplicitCombatModeRequest() => Trace.Add("intent");
public void SendChangeCombatMode(CombatMode mode)
{
Trace.Add($"send:{mode}");
if (ThrowOnSend)
throw new InvalidOperationException("transport");
}
}
}

View file

@ -1,11 +1,11 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.Core.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();
}
}

View file

@ -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);

View file

@ -0,0 +1,173 @@
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeSpellCastStateTests
{
[Fact]
public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
{
Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
uint target = 0;
var operations = new FakeOperations
{
LocalPlayerId = 42u,
SendTargetedAction = (id, _) => target = id,
};
RuntimeSpellCastState state = Create(book, operations, selected: 99u);
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
Assert.Equal(42u, target);
}
[Fact]
public void Cast_TargetedWithoutSelection_DoesNotSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
var operations = new FakeOperations { LocalPlayerId = 42u };
RuntimeSpellCastState state = Create(book, operations);
Assert.Equal(CastRequestResult.NoTarget, state.Cast(1));
Assert.Equal(0, operations.TargetedSends);
Assert.Contains(
"select",
operations.LastMessage,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var order = new List<string>();
var operations = new FakeOperations
{
LocalPlayerId = 42u,
StopAction = () => order.Add("stop"),
SendUntargetedAction = _ => order.Add("send"),
IncrementBusyAction = () => order.Add("busy"),
};
RuntimeSpellCastState state = Create(book, operations);
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
Assert.Equal(["stop", "send", "busy"], order);
}
[Fact]
public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var operations = new FakeOperations
{
LocalPlayerId = 42u,
HasComponents = false,
};
RuntimeSpellCastState state = Create(book, operations);
Assert.Equal(CastRequestResult.MissingComponents, state.Cast(1));
Assert.Equal(0, operations.UntargetedSends);
Assert.Equal(0, operations.BusyIncrements);
}
[Fact]
public void Cast_Disconnected_DoesNotIncrementBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var operations = new FakeOperations
{
LocalPlayerId = 42u,
CanSend = false,
};
RuntimeSpellCastState state = Create(book, operations);
Assert.Equal(CastRequestResult.Unavailable, state.Cast(1));
Assert.Equal(0, operations.BusyIncrements);
}
[Fact]
public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var operations = new FakeOperations
{
LocalPlayerId = 42u,
SendUntargetedAction =
_ => throw new InvalidOperationException("wire"),
};
RuntimeSpellCastState state = Create(book, operations);
Assert.Throws<InvalidOperationException>(() => state.Cast(1));
Assert.Equal(0, operations.BusyIncrements);
Assert.Null(state.LastRequestedSpellId);
}
private static RuntimeSpellCastState Create(
Spellbook book,
FakeOperations operations,
uint? selected = null)
{
var selection = new SelectionState();
if (selected is uint id)
selection.Select(id, SelectionChangeSource.System);
return new RuntimeSpellCastState(book, selection, operations);
}
private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
{
const string header =
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
SpellTable table = SpellTable.LoadFromReader(
new StringReader($"{header}\n{row}"));
var book = new Spellbook(table);
book.OnSpellLearned(1);
return book;
}
private sealed class FakeOperations : IRuntimeSpellCastOperations
{
public uint LocalPlayerId { get; init; }
public bool CanSend { get; init; } = true;
public bool HasComponents { get; init; } = true;
public bool TargetCompatible { get; init; } = true;
public int UntargetedSends { get; private set; }
public int TargetedSends { get; private set; }
public int BusyIncrements { get; private set; }
public string LastMessage { get; private set; } = string.Empty;
public Action? StopAction { get; init; }
public Action<uint>? SendUntargetedAction { get; init; }
public Action<uint, uint>? SendTargetedAction { get; init; }
public Action? IncrementBusyAction { get; init; }
public bool HasRequiredComponents(uint spellId) => HasComponents;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => TargetCompatible;
public void StopCompletely() => StopAction?.Invoke();
public void SendUntargeted(uint spellId)
{
UntargetedSends++;
SendUntargetedAction?.Invoke(spellId);
}
public void SendTargeted(uint targetId, uint spellId)
{
TargetedSends++;
SendTargetedAction?.Invoke(targetId, spellId);
}
public void DisplayMessage(string message) => LastMessage = message;
public void IncrementBusy()
{
BusyIncrements++;
IncrementBusyAction?.Invoke();
}
}
}