Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks. Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state. Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration. Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates. Co-Authored-By: Codex <noreply@openai.com>
234 lines
7.1 KiB
C#
234 lines
7.1 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|