refactor(runtime): own combat and magic intent
Move attack build/repeat state, combat-mode policy, authoritative auto-target transitions, and spell-cast intent beneath RuntimeActionState. Keep App as the input, world-query, DAT-policy, transport, and presentation adapter while preserving retail request and busy ordering. Add direct/graphical parity, reset, failure, and instance-isolation coverage. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
81b31857c6
commit
20df9d155d
49 changed files with 1949 additions and 634 deletions
|
|
@ -2,6 +2,7 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
|
@ -152,7 +153,7 @@ public sealed class LiveCombatAttackOperationsTests
|
|||
public void Show(string message) => Messages.Add(message);
|
||||
}
|
||||
|
||||
private sealed class FakeOperations : ICombatAttackOperations
|
||||
private sealed class FakeOperations : IRuntimeCombatAttackOperations
|
||||
{
|
||||
public bool CanStartValue { get; init; } = true;
|
||||
public bool CanStartAttack() => CanStartValue;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
|
|
@ -148,11 +149,13 @@ public sealed class LiveCombatModeCommandControllerTests
|
|||
Authority = new FakeAuthority(Calls);
|
||||
Equipment = new FakeEquipment();
|
||||
Intent = new FakeIntent(Calls);
|
||||
Controller = new LiveCombatModeCommandController(
|
||||
var operations = new LiveCombatModeOperations(
|
||||
Authority,
|
||||
Equipment,
|
||||
Combat,
|
||||
Intent,
|
||||
Intent);
|
||||
Runtime = new RuntimeCombatModeState(Combat, operations);
|
||||
Controller = new RuntimeCombatModeCommandAdapter(
|
||||
Runtime,
|
||||
line => Calls.Add($"log:{line}"),
|
||||
line => Calls.Add($"toast:{line}"),
|
||||
line => Calls.Add($"system:{line}"));
|
||||
|
|
@ -163,7 +166,8 @@ public sealed class LiveCombatModeCommandControllerTests
|
|||
public FakeEquipment Equipment { get; }
|
||||
public CombatState Combat { get; } = new();
|
||||
public FakeIntent Intent { get; }
|
||||
public LiveCombatModeCommandController Controller { get; }
|
||||
public RuntimeCombatModeState Runtime { get; }
|
||||
public RuntimeCombatModeCommandAdapter Controller { get; }
|
||||
}
|
||||
|
||||
private sealed class FakeAuthority(List<string> calls)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.Composition;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
|
|
@ -17,7 +22,6 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiCompositionPoint.InputCaptureBound,
|
||||
InteractionRetainedUiCompositionPoint.CursorAssetsCreated,
|
||||
InteractionRetainedUiCompositionPoint.CharacterSheetCreated,
|
||||
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
||||
InteractionRetainedUiCompositionPoint.MouseInputWired,
|
||||
InteractionRetainedUiCompositionPoint.KeyboardInputWired,
|
||||
InteractionRetainedUiCompositionPoint.UiAssetsCreated,
|
||||
|
|
@ -37,14 +41,15 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Assert.Equal(
|
||||
[
|
||||
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
||||
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
||||
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
||||
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
||||
.. UiPoints,
|
||||
InteractionRetainedUiCompositionPoint.ResultPublished,
|
||||
], fixture.Points);
|
||||
Assert.NotNull(result.RetainedUi);
|
||||
Assert.NotNull(result.Magic);
|
||||
Assert.Empty(fixture.Factory.Releases);
|
||||
}
|
||||
|
||||
|
|
@ -56,13 +61,14 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiResult result = fixture.Compose();
|
||||
|
||||
Assert.Null(result.RetainedUi);
|
||||
Assert.NotNull(result.Magic);
|
||||
Assert.Equal(
|
||||
[
|
||||
InteractionRetainedUiCompositionPoint.LateBindingsCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatAttackCreated,
|
||||
InteractionRetainedUiCompositionPoint.CombatTargetCreated,
|
||||
InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated,
|
||||
InteractionRetainedUiCompositionPoint.ItemInteractionCreated,
|
||||
InteractionRetainedUiCompositionPoint.MagicRuntimeCreated,
|
||||
InteractionRetainedUiCompositionPoint.RetainedUiDisabled,
|
||||
InteractionRetainedUiCompositionPoint.ResultPublished,
|
||||
], fixture.Points);
|
||||
|
|
@ -108,14 +114,12 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionRetainedUiCompositionPoint point)
|
||||
{
|
||||
var acquired = new List<string> { "late bindings" };
|
||||
if (point >= InteractionRetainedUiCompositionPoint.CombatAttackCreated)
|
||||
acquired.Add("combat attack");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.CombatTargetCreated)
|
||||
acquired.Add("combat target");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.ExternalContainerLifecycleCreated)
|
||||
acquired.Add("external container");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.ItemInteractionCreated)
|
||||
acquired.Add("item interaction");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.MagicRuntimeCreated)
|
||||
acquired.Add("magic runtime");
|
||||
if (point >= InteractionRetainedUiCompositionPoint.UiHostAcquired)
|
||||
acquired.Add("retained UI lease");
|
||||
acquired.Reverse();
|
||||
|
|
@ -132,10 +136,9 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"retained UI lease",
|
||||
"magic runtime",
|
||||
"item interaction",
|
||||
"external container",
|
||||
"combat target",
|
||||
"combat attack",
|
||||
"late bindings",
|
||||
], fixture.Factory.Releases);
|
||||
}
|
||||
|
|
@ -172,6 +175,13 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Publication = new Publication(publicationFailure);
|
||||
RuntimeOptions options = RuntimeOptions.Parse("dat", static _ => null)
|
||||
with { RetailUi = retailUi };
|
||||
var actions = new RuntimeActionState(
|
||||
new InventoryTransactionState(new ClientObjectTable()),
|
||||
new Spellbook(),
|
||||
new NoopCombatOperations(),
|
||||
new NoopCombatTargetOperations(),
|
||||
new NoopCombatModeOperations(),
|
||||
new NoopSpellOperations());
|
||||
Dependencies = new InteractionRetainedUiDependencies(
|
||||
Options: options,
|
||||
Gl: null!,
|
||||
|
|
@ -186,8 +196,10 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
RetainedInputCapture: null!,
|
||||
InputDispatcher: null,
|
||||
Settings: null!,
|
||||
Actions: null!,
|
||||
CombatAttackOperations: null!,
|
||||
Actions: actions,
|
||||
CombatAttackOperations: new NoopCombatOperations(),
|
||||
CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(),
|
||||
SpellCastOperations: new RuntimeSpellCastOperationsSlot(),
|
||||
Inventory: null!,
|
||||
MagicCatalog: null!,
|
||||
Character: null!,
|
||||
|
|
@ -233,14 +245,10 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
public List<string> Releases { get; } = [];
|
||||
public int RetainedUiCalls { get; private set; }
|
||||
|
||||
public CombatAttackController CreateCombatAttack(
|
||||
InteractionRetainedUiDependencies dependencies) =>
|
||||
Resource<CombatAttackController>("combat attack");
|
||||
|
||||
public CombatTargetController CreateCombatTarget(
|
||||
public IDisposable BindCombatTarget(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
DeferredSelectionUiAuthority selection) =>
|
||||
Resource<CombatTargetController>("combat target");
|
||||
new NoopDisposable();
|
||||
|
||||
public ExternalContainerLifecycleController CreateExternalContainerLifecycle(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
|
|
@ -252,12 +260,19 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
InteractionUiLateBindings lateBindings) =>
|
||||
Resource<ItemInteractionController>("item interaction");
|
||||
|
||||
public MagicRuntime CreateMagicRuntime(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings,
|
||||
ItemInteractionController itemInteraction) =>
|
||||
Resource<MagicRuntime>("magic runtime");
|
||||
|
||||
public RetainedUiComposition CreateRetainedUi(
|
||||
InteractionRetainedUiDependencies dependencies,
|
||||
InteractionUiLateBindings lateBindings,
|
||||
RetailUiRuntimeLease lease,
|
||||
CombatAttackController combatAttack,
|
||||
RuntimeCombatAttackState combatAttack,
|
||||
ItemInteractionController itemInteraction,
|
||||
MagicRuntime magic,
|
||||
Action<InteractionRetainedUiCompositionPoint> checkpoint)
|
||||
{
|
||||
RetainedUiCalls++;
|
||||
|
|
@ -270,7 +285,6 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
Stub<AcDream.UI.Abstractions.Panels.Vitals.VitalsVM>(),
|
||||
Stub<AcDream.UI.Abstractions.Panels.Chat.ChatVM>(),
|
||||
Stub<CharacterSheetProvider>(),
|
||||
Stub<AcDream.App.Spells.MagicRuntime>(),
|
||||
null);
|
||||
}
|
||||
|
||||
|
|
@ -296,6 +310,55 @@ public sealed class InteractionRetainedUiCompositionTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class NoopCombatOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
public bool CanStartAttack() => false;
|
||||
public void PrepareAttackRequest() { }
|
||||
public bool SendAttack(AttackHeight height, float power) => false;
|
||||
public void SendCancelAttack() { }
|
||||
public bool IsDualWield => false;
|
||||
public bool PlayerReadyForAttack => false;
|
||||
public bool AutoRepeatAttack => false;
|
||||
}
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class NoopSpellOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId => 0u;
|
||||
public bool CanSend => false;
|
||||
public bool HasRequiredComponents(uint spellId) => false;
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => false;
|
||||
public void StopCompletely() { }
|
||||
public void SendUntargeted(uint spellId) { }
|
||||
public void SendTargeted(uint targetId, uint spellId) { }
|
||||
public void DisplayMessage(string message) { }
|
||||
public void IncrementBusy() { }
|
||||
}
|
||||
|
||||
private sealed class NoopCombatTargetOperations
|
||||
: IRuntimeCombatTargetOperations
|
||||
{
|
||||
public bool AutoTarget => false;
|
||||
public uint? SelectClosestTarget() => null;
|
||||
}
|
||||
|
||||
private sealed class NoopCombatModeOperations
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
public bool IsInWorld => false;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
||||
public void NotifyExplicitCombatModeRequest() { }
|
||||
public void SendChangeCombatMode(CombatMode mode) { }
|
||||
}
|
||||
|
||||
private sealed class Publication(bool fail)
|
||||
: IGameWindowInteractionRetainedUiPublication
|
||||
{
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ public sealed class InteractionUiRuntimeSourcesTests
|
|||
public IRuntimeSessionCommands Session => null!;
|
||||
public IRuntimeSelectionCommands Selection => null!;
|
||||
public IRuntimeCombatCommands Combat => null!;
|
||||
public IRuntimeMagicCommands Magic => null!;
|
||||
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
|
||||
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
|
||||
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
|
@ -184,6 +185,13 @@ public sealed class GameplayInputCommandControllerTests
|
|||
RuntimeCommandStatus.Accepted,
|
||||
expectedGeneration);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult ExecuteAttack(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeCombatAttackInput command) =>
|
||||
new(
|
||||
RuntimeCommandStatus.Unsupported,
|
||||
expectedGeneration);
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeView : IGameRuntimeView
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.App.Input;
|
|||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Runtime;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.Update;
|
||||
|
|
@ -16,6 +17,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
|
@ -97,7 +99,12 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.True(portal.Accepted);
|
||||
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
|
||||
Assert.True(harness.MovementInput.AutoRunActive);
|
||||
Assert.Equal(1, harness.Combat.ToggleCount);
|
||||
Assert.Equal(
|
||||
[AcDream.Core.Combat.CombatMode.Melee],
|
||||
harness.CombatMode.Sent);
|
||||
Assert.Equal(
|
||||
AcDream.Core.Combat.CombatMode.Melee,
|
||||
harness.Actions.Combat.CurrentMode);
|
||||
Assert.Contains(
|
||||
harness.Commands.Published,
|
||||
static command => command is SendChatCmd
|
||||
|
|
@ -178,10 +185,89 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.Equal(active, stopped.RetiredGeneration);
|
||||
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
|
||||
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
|
||||
Assert.Equal(0, harness.Combat.ToggleCount);
|
||||
Assert.Empty(harness.CombatMode.Sent);
|
||||
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalAndDirectCombatMagicCommands_ReachTheExactSameOwners()
|
||||
{
|
||||
using var graphical = new Harness();
|
||||
using var direct = new Harness();
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
graphical.Runtime.Session.Start(graphical.Runtime.Generation).Status);
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
direct.Runtime.Session.Start(direct.Runtime.Generation).Status);
|
||||
var directTrace = new RuntimeTraceRecorder();
|
||||
using IDisposable directTraceSubscription =
|
||||
direct.Runtime.Subscribe(directTrace);
|
||||
|
||||
var graphicalAttack = new RecordingAttackOperations();
|
||||
var directAttack = new RecordingAttackOperations();
|
||||
var graphicalSpell = new RecordingSpellOperations();
|
||||
var directSpell = new RecordingSpellOperations();
|
||||
using IDisposable graphicalAttackBinding =
|
||||
graphical.CombatAttackOperations.BindOwned(graphicalAttack);
|
||||
using IDisposable directAttackBinding =
|
||||
direct.CombatAttackOperations.BindOwned(directAttack);
|
||||
using IDisposable graphicalSpellBinding =
|
||||
graphical.SpellCastOperations.BindOwned(graphicalSpell);
|
||||
using IDisposable directSpellBinding =
|
||||
direct.SpellCastOperations.BindOwned(directSpell);
|
||||
|
||||
PrepareCombatAndMagic(graphical);
|
||||
PrepareCombatAndMagic(direct);
|
||||
|
||||
var graphicalInput =
|
||||
new CombatAttackInputFrameAdapter(graphical.Actions.CombatAttack);
|
||||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Press));
|
||||
Assert.True(graphicalInput.HandleInputAction(
|
||||
InputAction.CombatLowAttack,
|
||||
ActivationType.Release));
|
||||
Assert.Equal(
|
||||
CastRequestResult.Sent,
|
||||
graphical.Actions.SpellCast.Cast(1u));
|
||||
|
||||
RuntimeGenerationToken generation = direct.Runtime.Generation;
|
||||
Assert.True(direct.Runtime.Combat.ExecuteAttack(
|
||||
generation,
|
||||
new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Press)).Accepted);
|
||||
Assert.True(direct.Runtime.Combat.ExecuteAttack(
|
||||
generation,
|
||||
new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Release)).Accepted);
|
||||
Assert.True(direct.Runtime.Magic.Execute(
|
||||
generation,
|
||||
new RuntimeMagicCommand(1u)).Accepted);
|
||||
|
||||
Assert.Equal(graphicalAttack.Trace, directAttack.Trace);
|
||||
Assert.Equal(graphicalSpell.Trace, directSpell.Trace);
|
||||
Assert.Equal(
|
||||
graphical.Actions.View.Snapshot.CombatAttack,
|
||||
direct.Actions.View.Snapshot.CombatAttack);
|
||||
Assert.Equal(
|
||||
graphical.Actions.View.Snapshot.Magic,
|
||||
direct.Actions.View.Snapshot.Magic);
|
||||
Assert.Equal(
|
||||
[
|
||||
RuntimeCommandDomain.Combat,
|
||||
RuntimeCommandDomain.Combat,
|
||||
RuntimeCommandDomain.Magic,
|
||||
],
|
||||
directTrace.Entries
|
||||
.Where(static entry => entry.Kind == RuntimeTraceKind.Command)
|
||||
.Select(static entry =>
|
||||
(RuntimeCommandDomain)(entry.Code >> 16))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
|
||||
{
|
||||
|
|
@ -668,6 +754,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
|
||||
private readonly ItemInteractionController _items;
|
||||
private readonly LiveSessionController _session;
|
||||
private readonly IDisposable _combatModeBinding;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
|
|
@ -681,14 +768,26 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
EntityObjects);
|
||||
Objects = EntityObjects.Objects;
|
||||
Communication = new RuntimeCommunicationState();
|
||||
Actions = new RuntimeActionState(InventoryState.Transactions);
|
||||
CombatAttackOperations = new CombatAttackOperationsSlot();
|
||||
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
|
||||
SpellCastOperations = new RuntimeSpellCastOperationsSlot();
|
||||
Actions = new RuntimeActionState(
|
||||
InventoryState.Transactions,
|
||||
Character.Spellbook,
|
||||
CombatAttackOperations,
|
||||
new RuntimeCombatTargetOperationsSlot(),
|
||||
CombatModeOperations,
|
||||
SpellCastOperations,
|
||||
now: static () => 0d);
|
||||
CombatMode = new RecordingCombatModeOperations();
|
||||
_combatModeBinding =
|
||||
CombatModeOperations.BindOwned(CombatMode);
|
||||
MovementInput = new DispatcherMovementInputSource();
|
||||
GameplayInput = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
MovementInput,
|
||||
mouseLook: null,
|
||||
new NoopCombatInput());
|
||||
Combat = new RecordingCombatCommand();
|
||||
Clock = new UpdateFrameClock();
|
||||
WorldReveal = new WorldRevealCoordinator(
|
||||
static (_, _) => true,
|
||||
|
|
@ -737,8 +836,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
WorldReveal,
|
||||
Clock,
|
||||
selectionController,
|
||||
GameplayInput,
|
||||
Combat);
|
||||
GameplayInput);
|
||||
}
|
||||
|
||||
public RuntimeOptions Options { get; }
|
||||
|
|
@ -750,11 +848,14 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
public ClientObjectTable Objects { get; }
|
||||
public RuntimeCommunicationState Communication { get; }
|
||||
public ChatLog Chat => Communication.Chat;
|
||||
public CombatAttackOperationsSlot CombatAttackOperations { get; }
|
||||
public RuntimeCombatModeOperationsSlot CombatModeOperations { get; }
|
||||
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
|
||||
public RuntimeActionState Actions { get; }
|
||||
public SelectionState Selection => Actions.Selection;
|
||||
public DispatcherMovementInputSource MovementInput { get; }
|
||||
public GameplayInputFrameController GameplayInput { get; }
|
||||
public RecordingCombatCommand Combat { get; }
|
||||
public RecordingCombatModeOperations CombatMode { get; }
|
||||
public UpdateFrameClock Clock { get; }
|
||||
public WorldRevealCoordinator WorldReveal { get; }
|
||||
public RecordingCommandRouting Commands { get; }
|
||||
|
|
@ -769,12 +870,72 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Communication.Dispose();
|
||||
_session.Dispose();
|
||||
_items.Dispose();
|
||||
_combatModeBinding.Dispose();
|
||||
Actions.Dispose();
|
||||
InventoryState.Dispose();
|
||||
Entities.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrepareCombatAndMagic(Harness harness)
|
||||
{
|
||||
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
|
||||
harness.Actions.CombatAttack.SetDesiredPower(0f);
|
||||
const string csv =
|
||||
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]\n"
|
||||
+ "1,Runtime Test Spell,0x0,true,0x0";
|
||||
harness.Character.InstallSpellMetadata(
|
||||
SpellTable.LoadFromReader(new System.IO.StringReader(csv)));
|
||||
harness.Character.Spellbook.OnSpellLearned(1u);
|
||||
}
|
||||
|
||||
private sealed class RecordingAttackOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
public List<string> Trace { get; } = [];
|
||||
public bool IsDualWield => false;
|
||||
public bool PlayerReadyForAttack => true;
|
||||
public bool AutoRepeatAttack => false;
|
||||
|
||||
public bool CanStartAttack() => true;
|
||||
|
||||
public void PrepareAttackRequest() => Trace.Add("prepare");
|
||||
|
||||
public bool SendAttack(
|
||||
AcDream.Core.Combat.AttackHeight height,
|
||||
float power)
|
||||
{
|
||||
Trace.Add($"attack:{height}:{power}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SendCancelAttack() => Trace.Add("cancel");
|
||||
}
|
||||
|
||||
private sealed class RecordingSpellOperations
|
||||
: IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId => Harness.PlayerGuid;
|
||||
public bool CanSend => true;
|
||||
public List<string> Trace { get; } = [];
|
||||
|
||||
public bool HasRequiredComponents(uint spellId) => true;
|
||||
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => true;
|
||||
|
||||
public void StopCompletely() => Trace.Add("stop");
|
||||
public void SendUntargeted(uint spellId) =>
|
||||
Trace.Add($"untargeted:{spellId}");
|
||||
public void SendTargeted(uint targetId, uint spellId) =>
|
||||
Trace.Add($"targeted:{targetId}:{spellId}");
|
||||
public void DisplayMessage(string message) =>
|
||||
Trace.Add($"message:{message}");
|
||||
public void IncrementBusy() => Trace.Add("busy");
|
||||
}
|
||||
|
||||
private static LiveSessionHost CreateHost(
|
||||
LiveSessionController controller,
|
||||
RecordingCommandRouting commands,
|
||||
|
|
@ -1031,10 +1192,15 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
public void Dispose() => _active = false;
|
||||
}
|
||||
|
||||
internal sealed class RecordingCombatCommand : ILiveCombatModeCommand
|
||||
internal sealed class RecordingCombatModeOperations
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
public int ToggleCount { get; private set; }
|
||||
public void Toggle() => ToggleCount++;
|
||||
public List<AcDream.Core.Combat.CombatMode> Sent { get; } = [];
|
||||
public bool IsInWorld => true;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
||||
public void NotifyExplicitCombatModeRequest() { }
|
||||
public void SendChangeCombatMode(
|
||||
AcDream.Core.Combat.CombatMode mode) => Sent.Add(mode);
|
||||
}
|
||||
|
||||
private sealed class NoopCombatInput : ICombatInputFrameController
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class RuntimeActionOwnershipTests
|
|||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);",
|
||||
"_runtimeActions = new RuntimeActionState(",
|
||||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
|
|
@ -61,6 +61,18 @@ public sealed class RuntimeActionOwnershipTests
|
|||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeInteractionTransactionState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatAttackState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatTargetState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeCombatModeState\s*\("));
|
||||
Assert.Empty(Regex.Matches(
|
||||
production,
|
||||
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeSpellCastState\s*\("));
|
||||
Assert.Equal(
|
||||
1,
|
||||
Regex.Matches(
|
||||
|
|
@ -104,6 +116,11 @@ public sealed class RuntimeActionOwnershipTests
|
|||
Assert.Contains("d.Actions.Transactions,", ui, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_dependencies.Actions.CombatAttack",
|
||||
ui,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.SpellCast", ui, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal);
|
||||
Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal);
|
||||
Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal);
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Tests.Spells;
|
||||
|
||||
public sealed class SpellCastingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
|
||||
uint target = 0;
|
||||
var controller = MakeController(book, selected: 99, player: 42,
|
||||
sendTargeted: (id, _) => target = id);
|
||||
|
||||
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
|
||||
Assert.Equal(42u, target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_TargetedWithoutSelection_DoesNotSend()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
|
||||
int sends = 0;
|
||||
string message = "";
|
||||
var controller = MakeController(book, selected: null, player: 42,
|
||||
sendTargeted: (_, _) => sends++, display: value => message = value);
|
||||
|
||||
Assert.Equal(CastRequestResult.NoTarget, controller.Cast(1));
|
||||
Assert.Equal(0, sends);
|
||||
Assert.Contains("select", message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
var order = new List<string>();
|
||||
var controller = MakeController(book, selected: null, player: 42,
|
||||
stop: () => order.Add("stop"),
|
||||
sendUntargeted: _ => order.Add("send"),
|
||||
incrementBusy: () => order.Add("busy"));
|
||||
|
||||
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
|
||||
Assert.Equal(["stop", "send", "busy"], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
int sends = 0, busy = 0;
|
||||
var controller = MakeController(book, selected: null, player: 42,
|
||||
sendUntargeted: _ => sends++, hasComponents: _ => false,
|
||||
incrementBusy: () => busy++);
|
||||
|
||||
Assert.Equal(CastRequestResult.MissingComponents, controller.Cast(1));
|
||||
Assert.Equal(0, sends);
|
||||
Assert.Equal(0, busy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_Disconnected_DoesNotIncrementBusy()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
int busy = 0;
|
||||
var controller = MakeController(book, selected: null, player: 42,
|
||||
incrementBusy: () => busy++, canSend: () => false);
|
||||
|
||||
Assert.Equal(CastRequestResult.Unavailable, controller.Cast(1));
|
||||
Assert.Equal(0, busy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
int increments = 0;
|
||||
var controller = MakeController(book, selected: null, player: 42,
|
||||
sendUntargeted: _ => throw new InvalidOperationException("wire"),
|
||||
incrementBusy: () => increments++);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Cast(1));
|
||||
Assert.Equal(0, increments);
|
||||
Assert.Null(controller.LastRequestedSpellId);
|
||||
}
|
||||
|
||||
private static SpellCastingController MakeController(
|
||||
Spellbook book,
|
||||
uint? selected,
|
||||
uint player,
|
||||
Action? stop = null,
|
||||
Action<uint>? sendUntargeted = null,
|
||||
Action<uint, uint>? sendTargeted = null,
|
||||
Action<string>? display = null,
|
||||
Func<uint, bool>? hasComponents = null,
|
||||
Action? incrementBusy = null,
|
||||
Func<bool>? canSend = null) => new(
|
||||
book,
|
||||
() => selected,
|
||||
() => player,
|
||||
stop ?? (() => { }),
|
||||
sendUntargeted ?? (_ => { }),
|
||||
sendTargeted ?? ((_, _) => { }),
|
||||
display ?? (_ => { }),
|
||||
hasRequiredComponents: hasComponents,
|
||||
incrementBusy: incrementBusy,
|
||||
canSend: canSend);
|
||||
|
||||
private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
|
||||
{
|
||||
const string header = "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
|
||||
string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
|
||||
SpellTable table = SpellTable.LoadFromReader(new StringReader($"{header}\n{row}"));
|
||||
var book = new Spellbook(table);
|
||||
book.OnSpellLearned(1);
|
||||
return book;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
|
@ -33,7 +34,7 @@ public sealed class CombatUiControllerTests
|
|||
Assert.False(high.Selected);
|
||||
Assert.True(medium.Selected);
|
||||
Assert.False(low.Selected);
|
||||
Assert.Equal(CombatAttackController.InitialDesiredPower, power.ScalarPosition);
|
||||
Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, power.ScalarPosition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -123,7 +124,7 @@ public sealed class CombatUiControllerTests
|
|||
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
|
||||
"High", "Medium", "Low");
|
||||
|
||||
private static CombatAttackController CreateAttacks(
|
||||
private static RuntimeCombatAttackState CreateAttacks(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
List<(AttackHeight Height, float Power)> sent)
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ public class SelectedObjectControllerTests
|
|||
h.StackMap[Replacement] = 1u;
|
||||
|
||||
h.Selection.Select(Dead, SelectionChangeSource.World);
|
||||
// CombatTargetController is registered before the retained toolbar in
|
||||
// RuntimeCombatTargetState is registered before the retained toolbar in
|
||||
// production. Its clear handler selects a replacement reentrantly.
|
||||
h.Selection.Changed += transition =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.App.UI.Layout;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -330,14 +331,17 @@ public sealed class SpellcastingUiControllerTests
|
|||
SelectionState? selection = null,
|
||||
Action<uint>? examineSpell = null)
|
||||
{
|
||||
var casting = new SpellCastingController(
|
||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||
SelectionState selectionState = selection ?? new SelectionState();
|
||||
var casting = new RuntimeSpellCastState(
|
||||
spellbook,
|
||||
selectionState,
|
||||
new NoopSpellCastOperations());
|
||||
return SpellcastingUiController.Bind(
|
||||
layout, spellbook, casting, objects, () => 1u,
|
||||
spellId => spellId,
|
||||
item => item.ObjectId,
|
||||
useItem,
|
||||
selection ?? new SelectionState(),
|
||||
selectionState,
|
||||
(tab, position, spellId) =>
|
||||
{
|
||||
spellbook.SetFavorite(tab, position, spellId);
|
||||
|
|
@ -349,6 +353,22 @@ public sealed class SpellcastingUiControllerTests
|
|||
examineSpell);
|
||||
}
|
||||
|
||||
private sealed class NoopSpellCastOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId => 1u;
|
||||
public bool CanSend => true;
|
||||
public bool HasRequiredComponents(uint spellId) => true;
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => true;
|
||||
public void StopCompletely() { }
|
||||
public void SendUntargeted(uint spellId) { }
|
||||
public void SendTargeted(uint targetId, uint spellId) { }
|
||||
public void DisplayMessage(string message) { }
|
||||
public void IncrementBusy() { }
|
||||
}
|
||||
|
||||
private static void ApplyAnchors(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
|
|
|
|||
|
|
@ -597,11 +597,11 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
field => field.FieldType == typeof(Silk.NET.Input.IMouse));
|
||||
|
||||
FieldInfo combatOperations = Assert.Single(
|
||||
typeof(AcDream.App.Combat.CombatAttackController).GetFields(
|
||||
typeof(AcDream.Runtime.Gameplay.RuntimeCombatAttackState).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => field.Name == "_operations");
|
||||
Assert.Equal(
|
||||
typeof(AcDream.App.Combat.ICombatAttackOperations),
|
||||
typeof(AcDream.Runtime.Gameplay.IRuntimeCombatAttackOperations),
|
||||
combatOperations.FieldType);
|
||||
Assert.DoesNotContain(
|
||||
typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields(
|
||||
|
|
|
|||
|
|
@ -192,7 +192,8 @@ public sealed class GameRuntimeContractTests
|
|||
Assert.Contains("social=9:10:11", entry.Text);
|
||||
Assert.Contains(
|
||||
"actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" +
|
||||
"00000000:00000000:00000000:0:0",
|
||||
"00000000:00000000:00000000:0:0:0:0:00000000:00000000:" +
|
||||
"False:False:00000000:0:00000000:00000000",
|
||||
entry.Text);
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +209,10 @@ public sealed class GameRuntimeContractTests
|
|||
typeof(RuntimeMovementSkillState),
|
||||
typeof(RuntimeActionState),
|
||||
typeof(InteractionState),
|
||||
typeof(RuntimeCombatAttackState),
|
||||
typeof(RuntimeCombatTargetState),
|
||||
typeof(RuntimeCombatModeState),
|
||||
typeof(RuntimeSpellCastState),
|
||||
];
|
||||
|
||||
foreach (Type owner in owners)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public sealed class RuntimeActionStateTests
|
|||
public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
|
||||
{
|
||||
using var inventory = NewInventoryTransactions();
|
||||
using var actions = new RuntimeActionState(inventory);
|
||||
using var actions = RuntimeActionTestFactory.Create(inventory);
|
||||
|
||||
actions.Selection.Select(
|
||||
0x50000001u,
|
||||
|
|
@ -41,7 +41,7 @@ public sealed class RuntimeActionStateTests
|
|||
public void ResetSessionConvergesEveryChildAfterObserverFailures()
|
||||
{
|
||||
using var inventory = NewInventoryTransactions();
|
||||
var actions = new RuntimeActionState(inventory);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory);
|
||||
actions.Selection.Select(
|
||||
0x50000001u,
|
||||
SelectionChangeSource.World);
|
||||
|
|
@ -73,7 +73,7 @@ public sealed class RuntimeActionStateTests
|
|||
public void DisposeIsTerminalAndConvergedAfterObserverFailures()
|
||||
{
|
||||
using var inventory = NewInventoryTransactions();
|
||||
var actions = new RuntimeActionState(inventory);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory);
|
||||
actions.Selection.Select(
|
||||
0x50000001u,
|
||||
SelectionChangeSource.World);
|
||||
|
|
@ -100,8 +100,8 @@ public sealed class RuntimeActionStateTests
|
|||
{
|
||||
using var firstInventory = NewInventoryTransactions();
|
||||
using var secondInventory = NewInventoryTransactions();
|
||||
using var first = new RuntimeActionState(firstInventory);
|
||||
using var second = new RuntimeActionState(secondInventory);
|
||||
using var first = RuntimeActionTestFactory.Create(firstInventory);
|
||||
using var second = RuntimeActionTestFactory.Create(secondInventory);
|
||||
|
||||
first.Selection.Select(
|
||||
0x50000001u,
|
||||
|
|
@ -131,7 +131,19 @@ public sealed class RuntimeActionStateTests
|
|||
OutboundCount: 0,
|
||||
HasPendingPickup: false,
|
||||
PendingPickupToken: 0u,
|
||||
DispatchFailureCount: 0)),
|
||||
DispatchFailureCount: 0),
|
||||
new RuntimeCombatAttackSnapshot(
|
||||
Revision: 0,
|
||||
RequestedHeight: AttackHeight.Medium,
|
||||
DesiredPower: 0.5f,
|
||||
PowerBarLevel: 0f,
|
||||
BuildInProgress: false,
|
||||
RequestInProgress: false,
|
||||
RequestedPower: 0f),
|
||||
new RuntimeSpellCastSnapshot(
|
||||
Revision: 0,
|
||||
LastRequestedSpellId: 0u,
|
||||
LastRequestedTargetId: 0u)),
|
||||
second.View.Snapshot);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
internal static class RuntimeActionTestFactory
|
||||
{
|
||||
public static RuntimeActionState Create(
|
||||
InventoryTransactionState inventory,
|
||||
Func<double>? now = null) =>
|
||||
new(
|
||||
inventory,
|
||||
new Spellbook(),
|
||||
new CombatOperations(),
|
||||
new CombatTargetOperations(),
|
||||
new CombatModeOperations(),
|
||||
new SpellOperations(),
|
||||
now);
|
||||
|
||||
private sealed class CombatOperations : IRuntimeCombatAttackOperations
|
||||
{
|
||||
public bool CanStartAttack() => false;
|
||||
public void PrepareAttackRequest() { }
|
||||
public bool SendAttack(AttackHeight height, float power) => false;
|
||||
public void SendCancelAttack() { }
|
||||
public bool IsDualWield => false;
|
||||
public bool PlayerReadyForAttack => false;
|
||||
public bool AutoRepeatAttack => false;
|
||||
}
|
||||
|
||||
private sealed class CombatTargetOperations
|
||||
: IRuntimeCombatTargetOperations
|
||||
{
|
||||
public bool AutoTarget => false;
|
||||
public uint? SelectClosestTarget() => null;
|
||||
}
|
||||
|
||||
private sealed class CombatModeOperations
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
public bool IsInWorld => false;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
||||
public void NotifyExplicitCombatModeRequest() { }
|
||||
public void SendChangeCombatMode(CombatMode mode) { }
|
||||
}
|
||||
|
||||
private sealed class SpellOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId => 0u;
|
||||
public bool CanSend => false;
|
||||
public bool HasRequiredComponents(uint spellId) => false;
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => false;
|
||||
public void StopCompletely() { }
|
||||
public void SendUntargeted(uint spellId) { }
|
||||
public void SendTargeted(uint targetId, uint spellId) { }
|
||||
public void DisplayMessage(string message) { }
|
||||
public void IncrementBusy() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class CombatAttackControllerTests
|
||||
public sealed class RuntimeCombatAttackStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
|
||||
|
|
@ -76,11 +75,13 @@ public sealed class CombatAttackControllerTests
|
|||
using var controller = Create(combat, () => now, sent);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Press));
|
||||
Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Press)));
|
||||
now = 1.5d;
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Release));
|
||||
Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.LowAttack,
|
||||
RuntimeInputActivation.Release)));
|
||||
|
||||
Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
|
||||
}
|
||||
|
|
@ -108,7 +109,7 @@ public sealed class CombatAttackControllerTests
|
|||
double now = 0d;
|
||||
bool ready = false;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
() => true,
|
||||
(_, _) => true,
|
||||
|
|
@ -135,7 +136,7 @@ public sealed class CombatAttackControllerTests
|
|||
int cancels = 0;
|
||||
var sent = new List<(AttackHeight Height, float Power)>();
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (height, power) =>
|
||||
|
|
@ -153,8 +154,9 @@ public sealed class CombatAttackControllerTests
|
|||
controller.ReleaseAttack();
|
||||
Assert.Single(sent);
|
||||
|
||||
controller.HandleMovementInput(
|
||||
InputAction.MovementForward, ActivationType.Press);
|
||||
controller.HandleCommand(new RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand.AbortForMovement,
|
||||
RuntimeInputActivation.Press));
|
||||
combat.OnAttackDone(1u, 0u);
|
||||
|
||||
Assert.Equal(1, cancels);
|
||||
|
|
@ -168,7 +170,7 @@ public sealed class CombatAttackControllerTests
|
|||
{
|
||||
int cancels = 0;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
|
|
@ -184,8 +186,8 @@ public sealed class CombatAttackControllerTests
|
|||
{
|
||||
var events = new List<string>();
|
||||
var combat = new CombatState();
|
||||
CombatAttackController? controller = null;
|
||||
controller = new CombatAttackController(
|
||||
RuntimeCombatAttackState? controller = null;
|
||||
controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) =>
|
||||
|
|
@ -221,7 +223,7 @@ public sealed class CombatAttackControllerTests
|
|||
double now = 1d;
|
||||
int cancels = 0;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
using var controller = new RuntimeCombatAttackState(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
|
|
@ -239,11 +241,11 @@ public sealed class CombatAttackControllerTests
|
|||
Assert.Equal(0f, controller.RequestedAttackPower);
|
||||
Assert.Equal(0f, controller.PowerBarLevel);
|
||||
Assert.Equal(AttackHeight.Medium, controller.RequestedHeight);
|
||||
Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower);
|
||||
Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, controller.DesiredPower);
|
||||
Assert.Equal(0, cancels);
|
||||
}
|
||||
|
||||
private static CombatAttackController Create(
|
||||
private static RuntimeCombatAttackState Create(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
List<(AttackHeight Height, float Power)> sent,
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class RuntimeCombatModeStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void OutsideWorld_IsCompleteNoOp()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var operations = new Operations { IsInWorld = false };
|
||||
var state = new RuntimeCombatModeState(combat, operations);
|
||||
|
||||
RuntimeCombatModeRequestResult result = state.Toggle();
|
||||
|
||||
Assert.Equal(RuntimeCombatModeRequestStatus.Inactive, result.Status);
|
||||
Assert.Empty(operations.Trace);
|
||||
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PeaceWithBow_OrdersIntentSendThenLocalState()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var operations = new Operations();
|
||||
operations.Equipment.Add(new ClientObject
|
||||
{
|
||||
ObjectId = 1u,
|
||||
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
||||
Type = ItemType.MissileWeapon,
|
||||
CombatUse = 2,
|
||||
});
|
||||
combat.CombatModeChanged += mode =>
|
||||
operations.Trace.Add($"state:{mode}");
|
||||
var state = new RuntimeCombatModeState(combat, operations);
|
||||
|
||||
RuntimeCombatModeRequestResult result = state.Toggle();
|
||||
|
||||
Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
|
||||
Assert.Equal(CombatMode.Missile, result.Mode);
|
||||
Assert.Equal(
|
||||
["intent", "equipment", "send:Missile", "state:Missile"],
|
||||
operations.Trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveCombat_LeavesWithoutEquipmentQuery()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
combat.SetCombatMode(CombatMode.Magic);
|
||||
var operations = new Operations();
|
||||
var state = new RuntimeCombatModeState(combat, operations);
|
||||
|
||||
RuntimeCombatModeRequestResult result = state.Toggle();
|
||||
|
||||
Assert.Equal(RuntimeCombatModeRequestStatus.Sent, result.Status);
|
||||
Assert.Equal(CombatMode.NonCombat, result.Mode);
|
||||
Assert.DoesNotContain("equipment", operations.Trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompatibleHeldItem_RejectsAfterExplicitIntent()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var operations = new Operations();
|
||||
operations.Equipment.Add(new ClientObject
|
||||
{
|
||||
ObjectId = 1u,
|
||||
Name = "Torch",
|
||||
CurrentlyEquippedLocation = EquipMask.Held,
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
var state = new RuntimeCombatModeState(combat, operations);
|
||||
|
||||
RuntimeCombatModeRequestResult result = state.Toggle();
|
||||
|
||||
Assert.Equal(RuntimeCombatModeRequestStatus.Rejected, result.Status);
|
||||
Assert.Equal(
|
||||
"You can't enter combat mode while wielding the Torch",
|
||||
result.Notice);
|
||||
Assert.Equal(["intent", "equipment"], operations.Trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransportFailure_DoesNotPublishLocalMode()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var operations = new Operations { ThrowOnSend = true };
|
||||
var state = new RuntimeCombatModeState(combat, operations);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => state.Toggle());
|
||||
|
||||
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
|
||||
Assert.Equal(["intent", "equipment", "send:Melee"], operations.Trace);
|
||||
}
|
||||
|
||||
private sealed class Operations : IRuntimeCombatModeOperations
|
||||
{
|
||||
public bool IsInWorld { get; init; } = true;
|
||||
public bool ThrowOnSend { get; init; }
|
||||
public List<ClientObject> Equipment { get; } = [];
|
||||
public List<string> Trace { get; } = [];
|
||||
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment()
|
||||
{
|
||||
Trace.Add("equipment");
|
||||
return Equipment;
|
||||
}
|
||||
|
||||
public void NotifyExplicitCombatModeRequest() => Trace.Add("intent");
|
||||
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
Trace.Add($"send:{mode}");
|
||||
if (ThrowOnSend)
|
||||
throw new InvalidOperationException("transport");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class CombatTargetControllerTests
|
||||
public sealed class RuntimeCombatTargetStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeadSelectedTarget_AutoTargetEnabled_SelectsClosestReplacement()
|
||||
|
|
@ -15,16 +15,15 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
autoTarget: () => true,
|
||||
selectClosestTarget: () =>
|
||||
new Operations(true, () =>
|
||||
{
|
||||
calls++;
|
||||
selection.Select(0x50000002u, SelectionChangeSource.System);
|
||||
return 0x50000002u;
|
||||
});
|
||||
}));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -40,8 +39,10 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => false, () => { calls++; return null; });
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(false, () => { calls++; return null; }));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -56,8 +57,10 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000001u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => true, () => { calls++; return null; });
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(true, () => { calls++; return null; }));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -72,8 +75,12 @@ public sealed class CombatTargetControllerTests
|
|||
combat.SetCombatMode(CombatMode.Missile);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(0x50000002u, SelectionChangeSource.World);
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => true, () => throw new InvalidOperationException());
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(
|
||||
true,
|
||||
() => throw new InvalidOperationException()));
|
||||
|
||||
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
|
||||
|
||||
|
|
@ -88,12 +95,23 @@ public sealed class CombatTargetControllerTests
|
|||
var selection = new SelectionState();
|
||||
selection.Select(0x50000002u, SelectionChangeSource.World);
|
||||
int calls = 0;
|
||||
using var controller = new CombatTargetController(
|
||||
combat, selection, () => true, () => { calls++; return null; });
|
||||
using var controller = new RuntimeCombatTargetState(
|
||||
combat,
|
||||
selection,
|
||||
new Operations(true, () => { calls++; return null; }));
|
||||
|
||||
selection.Reset();
|
||||
|
||||
Assert.Equal(0, calls);
|
||||
Assert.Null(selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
private sealed class Operations(
|
||||
bool autoTarget,
|
||||
Func<uint?> selectClosestTarget)
|
||||
: IRuntimeCombatTargetOperations
|
||||
{
|
||||
public bool AutoTarget { get; } = autoTarget;
|
||||
public uint? SelectClosestTarget() => selectClosestTarget();
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
var inventory = new RuntimeInventoryState(entities);
|
||||
var character = new RuntimeCharacterState();
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var actions = new RuntimeActionState(inventory.Transactions);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||
inventory.Shortcuts.Changed += static () => { };
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
|
||||
|
|
@ -114,7 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
var inventory = new RuntimeInventoryState(entities);
|
||||
var character = new RuntimeCharacterState();
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var actions = new RuntimeActionState(inventory.Transactions);
|
||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||
|
||||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class RuntimeSpellCastStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
|
||||
uint target = 0;
|
||||
var operations = new FakeOperations
|
||||
{
|
||||
LocalPlayerId = 42u,
|
||||
SendTargetedAction = (id, _) => target = id,
|
||||
};
|
||||
RuntimeSpellCastState state = Create(book, operations, selected: 99u);
|
||||
|
||||
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
|
||||
Assert.Equal(42u, target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_TargetedWithoutSelection_DoesNotSend()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
|
||||
var operations = new FakeOperations { LocalPlayerId = 42u };
|
||||
RuntimeSpellCastState state = Create(book, operations);
|
||||
|
||||
Assert.Equal(CastRequestResult.NoTarget, state.Cast(1));
|
||||
Assert.Equal(0, operations.TargetedSends);
|
||||
Assert.Contains(
|
||||
"select",
|
||||
operations.LastMessage,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
var order = new List<string>();
|
||||
var operations = new FakeOperations
|
||||
{
|
||||
LocalPlayerId = 42u,
|
||||
StopAction = () => order.Add("stop"),
|
||||
SendUntargetedAction = _ => order.Add("send"),
|
||||
IncrementBusyAction = () => order.Add("busy"),
|
||||
};
|
||||
RuntimeSpellCastState state = Create(book, operations);
|
||||
|
||||
Assert.Equal(CastRequestResult.Sent, state.Cast(1));
|
||||
Assert.Equal(["stop", "send", "busy"], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
var operations = new FakeOperations
|
||||
{
|
||||
LocalPlayerId = 42u,
|
||||
HasComponents = false,
|
||||
};
|
||||
RuntimeSpellCastState state = Create(book, operations);
|
||||
|
||||
Assert.Equal(CastRequestResult.MissingComponents, state.Cast(1));
|
||||
Assert.Equal(0, operations.UntargetedSends);
|
||||
Assert.Equal(0, operations.BusyIncrements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_Disconnected_DoesNotIncrementBusy()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
var operations = new FakeOperations
|
||||
{
|
||||
LocalPlayerId = 42u,
|
||||
CanSend = false,
|
||||
};
|
||||
RuntimeSpellCastState state = Create(book, operations);
|
||||
|
||||
Assert.Equal(CastRequestResult.Unavailable, state.Cast(1));
|
||||
Assert.Equal(0, operations.BusyIncrements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
|
||||
{
|
||||
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
|
||||
var operations = new FakeOperations
|
||||
{
|
||||
LocalPlayerId = 42u,
|
||||
SendUntargetedAction =
|
||||
_ => throw new InvalidOperationException("wire"),
|
||||
};
|
||||
RuntimeSpellCastState state = Create(book, operations);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => state.Cast(1));
|
||||
Assert.Equal(0, operations.BusyIncrements);
|
||||
Assert.Null(state.LastRequestedSpellId);
|
||||
}
|
||||
|
||||
private static RuntimeSpellCastState Create(
|
||||
Spellbook book,
|
||||
FakeOperations operations,
|
||||
uint? selected = null)
|
||||
{
|
||||
var selection = new SelectionState();
|
||||
if (selected is uint id)
|
||||
selection.Select(id, SelectionChangeSource.System);
|
||||
return new RuntimeSpellCastState(book, selection, operations);
|
||||
}
|
||||
|
||||
private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
|
||||
{
|
||||
const string header =
|
||||
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
|
||||
string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
|
||||
SpellTable table = SpellTable.LoadFromReader(
|
||||
new StringReader($"{header}\n{row}"));
|
||||
var book = new Spellbook(table);
|
||||
book.OnSpellLearned(1);
|
||||
return book;
|
||||
}
|
||||
|
||||
private sealed class FakeOperations : IRuntimeSpellCastOperations
|
||||
{
|
||||
public uint LocalPlayerId { get; init; }
|
||||
public bool CanSend { get; init; } = true;
|
||||
public bool HasComponents { get; init; } = true;
|
||||
public bool TargetCompatible { get; init; } = true;
|
||||
public int UntargetedSends { get; private set; }
|
||||
public int TargetedSends { get; private set; }
|
||||
public int BusyIncrements { get; private set; }
|
||||
public string LastMessage { get; private set; } = string.Empty;
|
||||
public Action? StopAction { get; init; }
|
||||
public Action<uint>? SendUntargetedAction { get; init; }
|
||||
public Action<uint, uint>? SendTargetedAction { get; init; }
|
||||
public Action? IncrementBusyAction { get; init; }
|
||||
|
||||
public bool HasRequiredComponents(uint spellId) => HasComponents;
|
||||
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => TargetCompatible;
|
||||
|
||||
public void StopCompletely() => StopAction?.Invoke();
|
||||
|
||||
public void SendUntargeted(uint spellId)
|
||||
{
|
||||
UntargetedSends++;
|
||||
SendUntargetedAction?.Invoke(spellId);
|
||||
}
|
||||
|
||||
public void SendTargeted(uint targetId, uint spellId)
|
||||
{
|
||||
TargetedSends++;
|
||||
SendTargetedAction?.Invoke(targetId, spellId);
|
||||
}
|
||||
|
||||
public void DisplayMessage(string message) => LastMessage = message;
|
||||
|
||||
public void IncrementBusy()
|
||||
{
|
||||
BusyIncrements++;
|
||||
IncrementBusyAction?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue