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
|
|
@ -1,262 +0,0 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
public sealed class CombatAttackControllerTests
|
||||
{
|
||||
[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.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Press));
|
||||
now = 1.5d;
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.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 CombatAttackController(
|
||||
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 CombatAttackController(
|
||||
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.HandleMovementInput(
|
||||
InputAction.MovementForward, ActivationType.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 CombatAttackController(
|
||||
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();
|
||||
CombatAttackController? controller = null;
|
||||
controller = new CombatAttackController(
|
||||
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 CombatAttackController(
|
||||
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(CombatAttackController.InitialDesiredPower, controller.DesiredPower);
|
||||
Assert.Equal(0, cancels);
|
||||
}
|
||||
|
||||
private static CombatAttackController 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);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
public sealed class CombatTargetControllerTests
|
||||
{
|
||||
[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 CombatTargetController(
|
||||
combat,
|
||||
selection,
|
||||
autoTarget: () => true,
|
||||
selectClosestTarget: () =>
|
||||
{
|
||||
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 CombatTargetController(
|
||||
combat, selection, () => 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 CombatTargetController(
|
||||
combat, selection, () => 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 CombatTargetController(
|
||||
combat, selection, () => 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 CombatTargetController(
|
||||
combat, selection, () => true, () => { calls++; return null; });
|
||||
|
||||
selection.Reset();
|
||||
|
||||
Assert.Equal(0, calls);
|
||||
Assert.Null(selection.SelectedObjectId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue