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

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

View file

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

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
using AcDream.Core.Net;
namespace AcDream.App.Tests.Combat;
@ -152,7 +153,7 @@ public sealed class LiveCombatAttackOperationsTests
public void Show(string message) => Messages.Add(message);
}
private sealed class FakeOperations : ICombatAttackOperations
private sealed class FakeOperations : IRuntimeCombatAttackOperations
{
public bool CanStartValue { get; init; } = true;
public bool CanStartAttack() => CanStartValue;

View file

@ -1,6 +1,7 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Combat;
@ -148,11 +149,13 @@ public sealed class LiveCombatModeCommandControllerTests
Authority = new FakeAuthority(Calls);
Equipment = new FakeEquipment();
Intent = new FakeIntent(Calls);
Controller = new LiveCombatModeCommandController(
var operations = new LiveCombatModeOperations(
Authority,
Equipment,
Combat,
Intent,
Intent);
Runtime = new RuntimeCombatModeState(Combat, operations);
Controller = new RuntimeCombatModeCommandAdapter(
Runtime,
line => Calls.Add($"log:{line}"),
line => Calls.Add($"toast:{line}"),
line => Calls.Add($"system:{line}"));
@ -163,7 +166,8 @@ public sealed class LiveCombatModeCommandControllerTests
public FakeEquipment Equipment { get; }
public CombatState Combat { get; } = new();
public FakeIntent Intent { get; }
public LiveCombatModeCommandController Controller { get; }
public RuntimeCombatModeState Runtime { get; }
public RuntimeCombatModeCommandAdapter Controller { get; }
}
private sealed class FakeAuthority(List<string> calls)