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

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