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