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

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

@ -0,0 +1,264 @@
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCombatAttackStateTests
{
[Fact]
public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
{
double now = 10d;
var sent = new List<(AttackHeight Height, float Power)>();
var combat = new CombatState();
using var controller = Create(combat, () => now, sent);
combat.SetCombatMode(CombatMode.Melee);
controller.PressAttack(AttackHeight.High);
now = 10.5d;
Assert.Equal(0.5f, controller.PowerBarLevel, 3);
controller.ReleaseAttack();
var attack = Assert.Single(sent);
Assert.Equal(AttackHeight.High, attack.Height);
Assert.Equal(0.5f, attack.Power, 3);
}
[Fact]
public void DualWieldStance_UsesRetailPointEightSecondPowerUpTime()
{
double now = 3d;
var combat = new CombatState();
using var controller = Create(
combat,
() => now,
[],
isDualWield: () => true);
combat.SetCombatMode(CombatMode.Melee);
controller.PressAttack(AttackHeight.Medium);
now = 3.4d;
Assert.Equal(0.5f, controller.PowerBarLevel, 3);
}
[Fact]
public void EarlyRelease_CommitsOnUseTimeTickAtReleasedPower()
{
double now = 0d;
var sent = new List<(AttackHeight Height, float Power)>();
var combat = new CombatState();
using var controller = Create(combat, () => now, sent);
combat.SetCombatMode(CombatMode.Missile);
controller.SetDesiredPower(1f);
controller.PressAttack(AttackHeight.Low);
now = 0.25d;
controller.ReleaseAttack();
Assert.Empty(sent);
now = 0.26d;
controller.Tick();
var attack = Assert.Single(sent);
Assert.Equal(AttackHeight.Low, attack.Height);
Assert.Equal(0.25f, attack.Power, 3);
}
[Fact]
public void HoldBinding_UsesPressAndReleaseTransitions()
{
double now = 1d;
var sent = new List<(AttackHeight Height, float Power)>();
var combat = new CombatState();
using var controller = Create(combat, () => now, sent);
combat.SetCombatMode(CombatMode.Melee);
Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.LowAttack,
RuntimeInputActivation.Press)));
now = 1.5d;
Assert.True(controller.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.LowAttack,
RuntimeInputActivation.Release)));
Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
}
[Fact]
public void LeavingTargetedCombat_CancelsAnActiveBuild()
{
double now = 0d;
var combat = new CombatState();
using var controller = Create(combat, () => now, []);
combat.SetCombatMode(CombatMode.Melee);
controller.PressAttack(AttackHeight.Medium);
now = 0.4d;
combat.SetCombatMode(CombatMode.NonCombat);
Assert.False(controller.BuildInProgress);
Assert.False(controller.AttackRequestInProgress);
Assert.Equal(0f, controller.PowerBarLevel);
}
[Fact]
public void RequestWaitsUntilPlayerReachesReadyStanceBeforeBuilding()
{
double now = 0d;
bool ready = false;
var combat = new CombatState();
using var controller = new RuntimeCombatAttackState(
combat,
() => true,
(_, _) => true,
playerReadyForAttack: () => ready,
now: () => now);
combat.SetCombatMode(CombatMode.Missile);
controller.PressAttack(AttackHeight.High);
Assert.True(controller.AttackRequestInProgress);
Assert.False(controller.BuildInProgress);
ready = true;
now = 5d;
controller.Tick();
Assert.True(controller.BuildInProgress);
Assert.Equal(0f, controller.PowerBarLevel);
}
[Fact]
public void MovementAbort_DuringRepeat_SendsCancelAndPreventsNextAttack()
{
double now = 20d;
int cancels = 0;
var sent = new List<(AttackHeight Height, float Power)>();
var combat = new CombatState();
using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (height, power) =>
{
sent.Add((height, power));
return true;
},
sendCancelAttack: () => cancels++,
autoRepeatAttack: () => true,
now: () => now);
combat.SetCombatMode(CombatMode.Melee);
controller.PressAttack(AttackHeight.Medium);
now += 0.5d;
controller.ReleaseAttack();
Assert.Single(sent);
controller.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.AbortForMovement,
RuntimeInputActivation.Press));
combat.OnAttackDone(1u, 0u);
Assert.Equal(1, cancels);
Assert.Single(sent);
Assert.False(controller.BuildInProgress);
Assert.Equal(0f, controller.PowerBarLevel);
}
[Fact]
public void MovementAbort_WhileIdle_DoesNotSendCancel()
{
int cancels = 0;
var combat = new CombatState();
using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,
sendCancelAttack: () => cancels++);
controller.AbortAutomaticAttack();
Assert.Equal(0, cancels);
}
[Fact]
public void StartAttackRequest_PreparesPlayerMovementBeforePowerBuild()
{
var events = new List<string>();
var combat = new CombatState();
RuntimeCombatAttackState? controller = null;
controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) =>
{
events.Add("send");
return true;
},
prepareAttackRequest: () =>
{
Assert.True(controller!.AttackRequestInProgress);
Assert.Equal(1f, controller.RequestedAttackPower);
events.Add("prepare");
});
using (controller)
{
combat.SetCombatMode(CombatMode.Missile);
controller.SetDesiredPower(0f);
controller.PressAttack(AttackHeight.Medium);
Assert.Equal(new[] { "prepare" }, events);
Assert.True(controller.AttackRequestInProgress);
Assert.True(controller.BuildInProgress);
controller.ReleaseAttack();
Assert.Equal(new[] { "prepare", "send" }, events);
}
}
[Fact]
public void ResetSession_RestoresRetailBeginDefaultsWithoutSendingCancel()
{
double now = 1d;
int cancels = 0;
var combat = new CombatState();
using var controller = new RuntimeCombatAttackState(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,
sendCancelAttack: () => cancels++,
now: () => now);
combat.SetCombatMode(CombatMode.Melee);
controller.SetDesiredPower(1f);
controller.PressAttack(AttackHeight.High);
now = 1.5d;
controller.ResetSession();
Assert.False(controller.AttackRequestInProgress);
Assert.False(controller.BuildInProgress);
Assert.Equal(0f, controller.RequestedAttackPower);
Assert.Equal(0f, controller.PowerBarLevel);
Assert.Equal(AttackHeight.Medium, controller.RequestedHeight);
Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, controller.DesiredPower);
Assert.Equal(0, cancels);
}
private static RuntimeCombatAttackState Create(
CombatState combat,
Func<double> now,
List<(AttackHeight Height, float Power)> sent,
Func<bool>? isDualWield = null)
=> new(
combat,
canStartAttack: () => true,
sendAttack: (height, power) =>
{
sent.Add((height, power));
return true;
},
isDualWield: isDualWield,
autoRepeatAttack: () => false,
now: now);
}

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

@ -0,0 +1,117 @@
using AcDream.Core.Combat;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCombatTargetStateTests
{
[Fact]
public void DeadSelectedTarget_AutoTargetEnabled_SelectsClosestReplacement()
{
var combat = new CombatState();
combat.SetCombatMode(CombatMode.Melee);
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0;
using var controller = new RuntimeCombatTargetState(
combat,
selection,
new Operations(true, () =>
{
calls++;
selection.Select(0x50000002u, SelectionChangeSource.System);
return 0x50000002u;
}));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
Assert.Equal(1, calls);
Assert.Equal(0x50000002u, selection.SelectedObjectId);
}
[Fact]
public void DeadSelectedTarget_AutoTargetDisabled_LeavesSelectionClear()
{
var combat = new CombatState();
combat.SetCombatMode(CombatMode.Melee);
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0;
using var controller = new RuntimeCombatTargetState(
combat,
selection,
new Operations(false, () => { calls++; return null; }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
Assert.Equal(0, calls);
Assert.Null(selection.SelectedObjectId);
}
[Fact]
public void DeadSelectedTarget_NonCombatMode_DoesNotAutoTarget()
{
var combat = new CombatState();
var selection = new SelectionState();
selection.Select(0x50000001u, SelectionChangeSource.World);
int calls = 0;
using var controller = new RuntimeCombatTargetState(
combat,
selection,
new Operations(true, () => { calls++; return null; }));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
Assert.Equal(0, calls);
Assert.Null(selection.SelectedObjectId);
}
[Fact]
public void DeadUnselectedObject_DoesNotDisturbCurrentTarget()
{
var combat = new CombatState();
combat.SetCombatMode(CombatMode.Missile);
var selection = new SelectionState();
selection.Select(0x50000002u, SelectionChangeSource.World);
using var controller = new RuntimeCombatTargetState(
combat,
selection,
new Operations(
true,
() => throw new InvalidOperationException()));
controller.OnMotionApplied(0x50000001u, MotionCommand.Dead);
Assert.Equal(0x50000002u, selection.SelectedObjectId);
}
[Fact]
public void SessionReset_DoesNotAcquireTargetFromDepartingWorld()
{
var combat = new CombatState();
combat.SetCombatMode(CombatMode.Missile);
var selection = new SelectionState();
selection.Select(0x50000002u, SelectionChangeSource.World);
int calls = 0;
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();
}
}
}