diff --git a/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs
new file mode 100644
index 00000000..30af64fd
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs
@@ -0,0 +1,65 @@
+using NUnit.Framework;
+using UnityEngine;
+using Nova.Gameplay.Match;
+using Nova.Simulation.Definitions;
+using Nova.Simulation.State;
+
+namespace Nova.Gameplay.Tests
+{
+ ///
+ /// Map size of art models (EditMode lane, D-113): buildings fill their
+ /// footprint, units keep their authored proportions under one metric
+ /// factor, small units get a readable minimum height.
+ ///
+ [TestFixture]
+ public class ArtViewScaleTests
+ {
+ [Test]
+ public void Building_ShrinksToFootprintWidth()
+ {
+ float f = ArtViewScale.Factor(UnitRole.Barracks, new Vector3(9f, 8f, 6f));
+ Assert.That(9f * f, Is.EqualTo(SimDefinitions.BuildingFootprintCells).Within(1e-4f));
+ }
+
+ [Test]
+ public void Building_SmallerThanFootprint_KeepsAuthoredSize()
+ {
+ Assert.That(ArtViewScale.Factor(UnitRole.Storage, new Vector3(2f, 2f, 2f)), Is.EqualTo(1f));
+ }
+
+ [Test]
+ public void Vehicles_ShareOneFactor_SoTheirRatiosSurvive()
+ {
+ float tank = ArtViewScale.Factor(UnitRole.BattleTank, new Vector3(7.5f, 6.5f, 7.5f));
+ float scout = ArtViewScale.Factor(UnitRole.ScoutVehicle, new Vector3(2.8f, 4f, 4.5f));
+ Assert.That(tank, Is.EqualTo(scout));
+ Assert.That(7.5f * tank, Is.LessThan(SimDefinitions.BuildingFootprintCells * 0.65f),
+ "a battle tank must read clearly smaller than a building");
+ }
+
+ [Test]
+ public void Infantry_GetsTheMinimumHeight_IndependentOfWeaponWidth()
+ {
+ float rifle = ArtViewScale.Factor(UnitRole.BasicInfantry, new Vector3(0.8f, 1.8f, 0.9f));
+ float wideLauncher = ArtViewScale.Factor(UnitRole.BasicInfantry, new Vector3(1.5f, 1.8f, 0.9f));
+ Assert.That(1.8f * rifle, Is.EqualTo(ArtViewScale.MinViewHeight(UnitRole.BasicInfantry)).Within(1e-4f));
+ Assert.That(wideLauncher, Is.EqualTo(rifle), "width must not shrink a soldier");
+ }
+
+ [Test]
+ public void Infantry_StaysShorterThanABattleTank()
+ {
+ float tankHeight = 6.5f * ArtViewScale.Factor(UnitRole.BattleTank, new Vector3(7.5f, 6.5f, 7.5f));
+ float soldier = 1.8f * ArtViewScale.Factor(UnitRole.BasicInfantry, new Vector3(0.8f, 1.8f, 0.9f));
+ Assert.That(soldier, Is.LessThan(tankHeight * 0.7f));
+ }
+
+ [Test]
+ public void TryFactor_ResolvesRoleNames_AndRejectsUnknownOnes()
+ {
+ Assert.That(ArtViewScale.TryFactor("LightTank", Vector3.one, out float f), Is.True);
+ Assert.That(f, Is.EqualTo(ArtViewScale.UnitCellsPerMetre));
+ Assert.That(ArtViewScale.TryFactor("Zeppelin", Vector3.one, out _), Is.False);
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta
new file mode 100644
index 00000000..3bae6b56
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 475b6bd96c894dcebfd61f9e3c9a7b4b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
index e26567fd..cdb85b90 100644
--- a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
+++ b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs
@@ -358,6 +358,145 @@ public void TryFindRepairBuilder_NoOwnBuilder_ReturnsFalse()
Assert.IsFalse(CommandCardPresenter.TryFindRepairBuilder(null, playerSlot: 0, out _));
}
+ // ----------------------------------------------------------------
+ // Unit command bar: per-unit-type presence and harvester readout (D-117)
+ // ----------------------------------------------------------------
+
+ [Test]
+ public void GetBarCommands_Harvester_HidesCombatOrdersAndFormations()
+ {
+ var presenter = new CommandCardPresenter();
+
+ BarCommand bar = presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.Harvester });
+
+ Assert.IsTrue(bar.HasFlag(BarCommand.Move));
+ Assert.IsTrue(bar.HasFlag(BarCommand.Stop));
+ Assert.IsTrue(bar.HasFlag(BarCommand.Harvest));
+ Assert.IsTrue(bar.HasFlag(BarCommand.ReturnCargo));
+ foreach (BarCommand hidden in new[]
+ {
+ BarCommand.AttackMove, BarCommand.Guard, BarCommand.Patrol,
+ BarCommand.AttackTarget, BarCommand.Scatter, BarCommand.Formations, BarCommand.Repair,
+ })
+ {
+ Assert.IsFalse(bar.HasFlag(hidden), $"{hidden} must be hidden for harvesters");
+ }
+ }
+
+ [Test]
+ public void GetBarCommands_Builder_OffersRepairButNoCombatOrders()
+ {
+ var presenter = new CommandCardPresenter();
+
+ BarCommand bar = presenter.GetBarCommands(FactionId.Legion, new[] { UnitRole.Builder });
+
+ Assert.IsTrue(bar.HasFlag(BarCommand.Repair));
+ Assert.IsFalse(bar.HasFlag(BarCommand.Patrol));
+ Assert.IsFalse(bar.HasFlag(BarCommand.Formations));
+ Assert.IsFalse(bar.HasFlag(BarCommand.Harvest));
+ }
+
+ [Test]
+ public void GetBarCommands_ArmedUnits_OfferCombatOrdersAndFormationsButNoRoleActions()
+ {
+ var presenter = new CommandCardPresenter();
+
+ BarCommand bar = presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.LightTank, UnitRole.BasicInfantry });
+
+ foreach (BarCommand shown in new[]
+ {
+ BarCommand.Move, BarCommand.Stop, BarCommand.AttackMove, BarCommand.Guard, BarCommand.Patrol,
+ BarCommand.AttackTarget, BarCommand.Scatter, BarCommand.Formations,
+ })
+ {
+ Assert.IsTrue(bar.HasFlag(shown), $"{shown} must be shown for armed units");
+ }
+ Assert.IsFalse(bar.HasFlag(BarCommand.Harvest));
+ Assert.IsFalse(bar.HasFlag(BarCommand.ReturnCargo));
+ Assert.IsFalse(bar.HasFlag(BarCommand.Repair));
+ }
+
+ [Test]
+ public void GetBarCommands_MixedSelection_CombatGroupIsUnionRoleActionsAreIntersection()
+ {
+ var presenter = new CommandCardPresenter();
+
+ BarCommand bar = presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.LightTank });
+
+ Assert.IsTrue(bar.HasFlag(BarCommand.Guard), "one armed unit is enough for the combat group");
+ Assert.IsTrue(bar.HasFlag(BarCommand.Formations));
+ Assert.IsFalse(bar.HasFlag(BarCommand.Harvest), "Harvest would be a lie over the tank");
+ Assert.IsFalse(bar.HasFlag(BarCommand.ReturnCargo));
+ }
+
+ [Test]
+ public void GetBarCommands_NoMobileRole_ReturnsNone()
+ {
+ var presenter = new CommandCardPresenter();
+
+ Assert.AreEqual(BarCommand.None, presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.HQ }));
+ Assert.AreEqual(BarCommand.None, presenter.GetBarCommands(FactionId.Alliance, new UnitRole[0]));
+ }
+
+ [Test]
+ public void IsArmedRole_FollowsTheDefinitionRow()
+ {
+ Assert.IsTrue(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.LightTank));
+ Assert.IsFalse(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.Harvester));
+ Assert.IsFalse(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.Builder));
+ Assert.IsFalse(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.HQ));
+ }
+
+ [Test]
+ public void GetHarvesterStatus_DerivesFromOrdersAndMovement()
+ {
+ var u = new UnitState();
+ Assert.AreEqual(HarvesterStatus.Idle, CommandCardPresenter.GetHarvesterStatus(in u));
+
+ u.HarvestFieldId = 3;
+ u.IsMoving = true;
+ Assert.AreEqual(HarvesterStatus.DrivingToField, CommandCardPresenter.GetHarvesterStatus(in u));
+ u.IsMoving = false;
+ Assert.AreEqual(HarvesterStatus.Harvesting, CommandCardPresenter.GetHarvesterStatus(in u));
+
+ u.IsReturningCargo = true;
+ u.IsMoving = true;
+ Assert.AreEqual(HarvesterStatus.DrivingToRefinery, CommandCardPresenter.GetHarvesterStatus(in u));
+ u.IsMoving = false;
+ Assert.AreEqual(HarvesterStatus.WaitingAtRefinery, CommandCardPresenter.GetHarvesterStatus(in u));
+
+ Assert.AreEqual("Fährt zum Feld", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.DrivingToField));
+ Assert.AreEqual("Sammelt", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.Harvesting));
+ Assert.AreEqual("Fährt zur Raffinerie", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.DrivingToRefinery));
+ Assert.AreEqual("Wartet an der Raffinerie", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.WaitingAtRefinery));
+ Assert.AreEqual("Untätig", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.Idle));
+ }
+
+ [Test]
+ public void SummarizeHarvesters_SumsCargoCapacityAndStatus_SkippingOtherRoles()
+ {
+ var entities = new EntityManager(8);
+ EntityId a = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Harvester);
+ EntityId b = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.Harvester);
+ EntityId tank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(3), SimFixed.FromInt(3)), SimFixed.FromInt(3), role: UnitRole.LightTank);
+ entities.GetUnitRef(a).CargoAE = 210;
+ entities.GetUnitRef(a).HarvestFieldId = 1;
+ entities.GetUnitRef(b).CargoAE = 100;
+ int cap = SimDefinitions.HarvesterCargoCapacityAE(FactionId.Legion);
+ var presenter = new CommandCardPresenter();
+
+ HarvesterSummary sum = presenter.SummarizeHarvesters(FactionId.Legion, new[] { a, b, tank }, entities);
+
+ Assert.AreEqual(2, sum.Count);
+ Assert.AreEqual(310, sum.CargoAE);
+ Assert.AreEqual(2 * cap, sum.CapacityAE);
+ Assert.AreEqual(1, sum.Harvesting);
+ Assert.AreEqual(1, sum.Idle);
+ Assert.AreEqual(310f / (2 * cap), sum.Fill, 1e-5f);
+ Assert.AreEqual($"Ladung 310 / {2 * cap}", CommandCardPresenter.FormatCargo(sum.CargoAE, sum.CapacityAE));
+ Assert.AreEqual("1× Sammelt · 1× Untätig", CommandCardPresenter.FormatHarvesterStatusLine(in sum));
+ }
+
// ----------------------------------------------------------------
// Display names (canonical GDD name tables)
// ----------------------------------------------------------------
diff --git a/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs b/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs
index a2553e24..c5f1581b 100644
--- a/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs
+++ b/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs
@@ -49,5 +49,20 @@ public void ApplyToPropertyBlock_SetsBothShaderColourProperties()
Assert.That(block.GetColor("_Color"), Is.EqualTo(tint),
"built-in RP reads _Color");
}
+
+ [Test]
+ public void PrefabBaseColor_PreservesHighAlbedoAndDistinguishesFactions()
+ {
+ Color alliance = FactionTint.PrefabBaseColor(FactionId.Alliance);
+ Color legion = FactionTint.PrefabBaseColor(FactionId.Legion);
+
+ Assert.That(alliance, Is.Not.EqualTo(legion));
+ Assert.That(alliance.r, Is.GreaterThan(0.85f), "3D prefab tint must not crush red channel");
+ Assert.That(alliance.g, Is.GreaterThan(0.85f), "3D prefab tint must not crush green channel");
+ Assert.That(alliance.b, Is.GreaterThan(0.85f), "3D prefab tint must not crush blue channel");
+ Assert.That(legion.r, Is.GreaterThan(0.85f), "3D prefab tint must not crush red channel");
+ Assert.That(legion.g, Is.GreaterThan(0.85f), "3D prefab tint must not crush green channel");
+ Assert.That(legion.b, Is.GreaterThan(0.85f), "3D prefab tint must not crush blue channel");
+ }
}
}
diff --git a/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs
new file mode 100644
index 00000000..3bb4111e
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs
@@ -0,0 +1,127 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEngine;
+using Nova.Gameplay;
+using EntityId = Nova.Core.EntityId;
+
+namespace Nova.Gameplay.Tests
+{
+ /// Client-side formation slots (D-117): shape, facing, spacing and crossing-free assignment.
+ [TestFixture]
+ public class FormationPlannerTests
+ {
+ private static readonly Vector2 MapMin = Vector2.zero;
+ private static readonly Vector2 MapMax = new Vector2(127f, 127f);
+
+ private static List Group(int n, Vector2 at, float radius = 0.3f)
+ {
+ var list = new List();
+ for (int i = 0; i < n; i++)
+ {
+ list.Add(new FormationMember(new EntityId(i, 1), at + new Vector2((i % 4) * 1.2f, (i / 4) * 1.2f), radius));
+ }
+ return list;
+ }
+
+ [Test]
+ public void Loose_AndSingleUnits_LeaveTheSpreadToTheSimulation()
+ {
+ var slots = new List();
+ Assert.That(FormationPlanner.Plan(FormationKind.Loose, Group(6, new Vector2(10, 10)), new Vector2(40, 40), MapMin, MapMax, slots), Is.False);
+ Assert.That(FormationPlanner.Plan(FormationKind.Line, Group(1, new Vector2(10, 10)), new Vector2(40, 40), MapMin, MapMax, slots), Is.False);
+ }
+
+ [TestCase(FormationKind.Line)]
+ [TestCase(FormationKind.Column)]
+ [TestCase(FormationKind.Wedge)]
+ [TestCase(FormationKind.Block)]
+ public void EverySlot_KeepsAtLeastTheContactSpacing(FormationKind kind)
+ {
+ var members = Group(9, new Vector2(10, 10), 0.6f);
+ var slots = new List();
+ Assert.That(FormationPlanner.Plan(kind, members, new Vector2(40, 30), MapMin, MapMax, slots), Is.True);
+ Assert.That(slots.Count, Is.EqualTo(members.Count));
+ for (int a = 0; a < slots.Count; a++)
+ {
+ for (int b = a + 1; b < slots.Count; b++)
+ {
+ Assert.That((slots[a] - slots[b]).magnitude, Is.GreaterThanOrEqualTo(2f * 0.6f * 0.84f),
+ $"{kind}: slots {a} and {b} overlap");
+ }
+ }
+ }
+
+ [Test]
+ public void Line_StandsAcrossTheDirectionOfTravel()
+ {
+ var members = Group(5, new Vector2(10, 10));
+ var slots = new List();
+ Vector2 centroid = Vector2.zero;
+ foreach (FormationMember m in members) centroid += m.Position;
+ centroid /= members.Count;
+ Vector2 target = centroid + new Vector2(0f, 30f); // travel due north
+ FormationPlanner.Plan(FormationKind.Line, members, target, MapMin, MapMax, slots);
+ foreach (Vector2 s in slots)
+ {
+ Assert.That(s.y, Is.EqualTo(target.y).Within(0.01f), "a line facing north lies east-west");
+ }
+ }
+
+ [Test]
+ public void Column_RunsAlongTheDirectionOfTravel()
+ {
+ var members = Group(6, new Vector2(10, 10));
+ var slots = new List();
+ FormationPlanner.Plan(FormationKind.Column, members, new Vector2(40f, 11f), MapMin, MapMax, slots);
+ float minX = float.MaxValue, maxX = float.MinValue, minY = float.MaxValue, maxY = float.MinValue;
+ foreach (Vector2 s in slots)
+ {
+ minX = Mathf.Min(minX, s.x); maxX = Mathf.Max(maxX, s.x);
+ minY = Mathf.Min(minY, s.y); maxY = Mathf.Max(maxY, s.y);
+ }
+ Assert.That(maxX - minX, Is.GreaterThan(maxY - minY), "a column heading east is longer east-west");
+ }
+
+ [Test]
+ public void Wedge_PutsOneUnitAtTheTip()
+ {
+ var members = Group(5, new Vector2(10, 10));
+ var slots = new List();
+ FormationPlanner.Plan(FormationKind.Wedge, members, new Vector2(10.6f, 40f), MapMin, MapMax, slots);
+ float best = float.MinValue;
+ int tips = 0;
+ foreach (Vector2 s in slots) best = Mathf.Max(best, s.y);
+ foreach (Vector2 s in slots) if (Mathf.Abs(s.y - best) < 0.01f) tips++;
+ Assert.That(tips, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void Assignment_DoesNotCrossLeftAndRight()
+ {
+ // Three units side by side moving north: the westmost keeps the west slot.
+ var members = new List
+ {
+ new FormationMember(new EntityId(0, 1), new Vector2(20, 10), 0.3f),
+ new FormationMember(new EntityId(1, 1), new Vector2(10, 10), 0.3f),
+ new FormationMember(new EntityId(2, 1), new Vector2(15, 10), 0.3f),
+ };
+ var slots = new List();
+ FormationPlanner.Plan(FormationKind.Line, members, new Vector2(15, 40), MapMin, MapMax, slots);
+ Assert.That(slots[1].x, Is.LessThan(slots[2].x));
+ Assert.That(slots[2].x, Is.LessThan(slots[0].x));
+ }
+
+ [Test]
+ public void Slots_StayInsideTheMap()
+ {
+ var members = Group(12, new Vector2(3, 3));
+ var slots = new List();
+ FormationPlanner.Plan(FormationKind.Line, members, new Vector2(0.5f, 0.5f), MapMin, MapMax, slots);
+ foreach (Vector2 s in slots)
+ {
+ Assert.That(s.x, Is.InRange(0f, 127f));
+ Assert.That(s.y, Is.InRange(0f, 127f));
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta
new file mode 100644
index 00000000..bef3afc4
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ff244b9fb9af4fdfbd230bfc800e4def
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs
new file mode 100644
index 00000000..1d799b21
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs
@@ -0,0 +1,74 @@
+using NUnit.Framework;
+using Nova.Gameplay.Match;
+
+namespace Nova.Gameplay.Tests
+{
+ ///
+ /// The unload timeline of a harvester view (pure logic): ease in/out of
+ /// the bed, the pile letting go while it is tipped, a total of about two seconds.
+ ///
+ [TestFixture]
+ public class HarvesterDumpCycleTests
+ {
+ private static HarvesterDumpCycle Run(float seconds)
+ {
+ var c = new HarvesterDumpCycle();
+ c.Start();
+ const float step = 1f / 60f;
+ // Whole frames plus the remainder: a float accumulator stops up to a frame short.
+ int frames = (int)(seconds / step);
+ for (int i = 0; i < frames; i++) c.Advance(step);
+ c.Advance(seconds - frames * step);
+ return c;
+ }
+
+ [Test]
+ public void Idle_IsFlatAndInactive()
+ {
+ var c = new HarvesterDumpCycle();
+ Assert.IsFalse(c.Active);
+ Assert.AreEqual(0f, c.Tilt01);
+ Assert.AreEqual(0f, c.Release01);
+ }
+
+ [Test]
+ public void Tilt_RisesWithEaseInOut_HoldsAndReturns()
+ {
+ Assert.That(Run(0.05f).Tilt01, Is.LessThan(0.02f), "ease-in: slow start");
+ Assert.That(Run(HarvesterDumpCycle.TiltUpSeconds * 0.5f).Tilt01, Is.EqualTo(0.5f).Within(0.03f));
+ Assert.That(Run(HarvesterDumpCycle.TiltUpSeconds + 0.2f).Tilt01, Is.EqualTo(1f).Within(1e-3f), "hold");
+ float back = Run(HarvesterDumpCycle.TiltUpSeconds + HarvesterDumpCycle.HoldSeconds + HarvesterDumpCycle.ReturnSeconds * 0.5f).Tilt01;
+ Assert.That(back, Is.EqualTo(0.5f).Within(0.03f), "halfway back at half the return time");
+ }
+
+ [Test]
+ public void Release_StartsWhenTipped_AndFinishes()
+ {
+ Assert.AreEqual(0f, Run(HarvesterDumpCycle.ReleaseStartSeconds - 0.02f).Release01, 1e-4f);
+ float mid = Run(HarvesterDumpCycle.ReleaseStartSeconds + HarvesterDumpCycle.ReleaseSeconds * 0.5f).Release01;
+ Assert.That(mid, Is.EqualTo(0.25f).Within(0.03f), "accelerating slide");
+ Assert.That(Run(HarvesterDumpCycle.ReleaseStartSeconds + HarvesterDumpCycle.ReleaseSeconds + 0.02f).Release01, Is.EqualTo(1f).Within(1e-3f));
+ }
+
+ [Test]
+ public void Cycle_EndsAfterAboutTwoSeconds()
+ {
+ Assert.That(HarvesterDumpCycle.TotalSeconds, Is.InRange(1.7f, 2.1f));
+ Assert.IsTrue(Run(HarvesterDumpCycle.TotalSeconds - 0.05f).Active);
+ var done = Run(HarvesterDumpCycle.TotalSeconds + 0.1f);
+ Assert.IsFalse(done.Active);
+ Assert.AreEqual(0f, done.Tilt01);
+ }
+
+ [Test]
+ public void Restart_BeginsAgain_AndResetStops()
+ {
+ var c = Run(1.0f);
+ c.Start();
+ Assert.That(c.Tilt01, Is.EqualTo(0f).Within(1e-4f));
+ c.Advance(0.1f);
+ c.Reset();
+ Assert.IsFalse(c.Active);
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta
new file mode 100644
index 00000000..321ef42b
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d75d408a2f02a4885b4bab2708207f64
\ No newline at end of file
diff --git a/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs
new file mode 100644
index 00000000..c48d7fd4
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs
@@ -0,0 +1,165 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEngine;
+using Nova.Core;
+using Nova.Gameplay;
+using Nova.Simulation.State;
+using EntityId = Nova.Core.EntityId;
+
+namespace Nova.Gameplay.Tests
+{
+ /// Attack-move, patrol and guard as client-side orders over plain commands (D-117).
+ [TestFixture]
+ public class StandingOrdersTests
+ {
+ private sealed class FakeWorld : IStandingOrderWorld
+ {
+ public readonly Dictionary Own = new Dictionary();
+ public readonly Dictionary Enemies = new Dictionary();
+ public float Range = 5f;
+
+ public bool TryGetOwnUnit(EntityId id, out UnitState unit) => Own.TryGetValue(id, out unit);
+ public float WeaponRange(in UnitState unit) => Range;
+ public bool IsAliveAndVisible(EntityId enemy) => Enemies.ContainsKey(enemy);
+
+ public bool TryFindVisibleEnemy(Vector2 centre, float radius, out EntityId enemy)
+ {
+ enemy = EntityId.Invalid;
+ float best = radius;
+ foreach (KeyValuePair kv in Enemies)
+ {
+ float d = (kv.Value - centre).magnitude;
+ if (d <= best) { best = d; enemy = kv.Key; }
+ }
+ return enemy.IsValid;
+ }
+
+ public EntityId Add(int index, float x, float y, bool moving = false)
+ {
+ var id = new EntityId(index, 1);
+ var u = new UnitState { Id = id, IsActive = true, IsMoving = moving, AttackTarget = EntityId.Invalid,
+ Transform = new Transform2D(SimFixed.FromFloat(x), SimFixed.FromFloat(y)) };
+ Own[id] = u;
+ return id;
+ }
+
+ public void Place(EntityId id, float x, float y, bool moving)
+ {
+ UnitState u = Own[id];
+ u.Transform = new Transform2D(SimFixed.FromFloat(x), SimFixed.FromFloat(y));
+ u.IsMoving = moving;
+ Own[id] = u;
+ }
+ }
+
+ [Test]
+ public void AttackMove_MovesOn_AttacksWhatItMeets_AndEndsOnArrival()
+ {
+ var world = new FakeWorld();
+ EntityId tank = world.Add(1, 10, 10);
+ var orders = new StandingOrders();
+ orders.SetAttackMove(tank, new Vector2(40, 10));
+ var actions = new List();
+
+ orders.Tick(world, actions);
+ Assert.That(actions, Has.Count.EqualTo(1));
+ Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Move));
+
+ world.Place(tank, 20, 10, moving: true);
+ world.Enemies[new EntityId(9, 1)] = new Vector2(26, 11);
+ actions.Clear();
+ orders.Tick(world, actions);
+ Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Attack), "an enemy in reach is engaged");
+
+ world.Enemies.Clear();
+ world.Place(tank, 22, 10, moving: false);
+ actions.Clear();
+ orders.Tick(world, actions);
+ Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Move), "after the fight the drive resumes");
+ Assert.That(actions[0].Point, Is.EqualTo(new Vector2(40, 10)));
+
+ world.Place(tank, 39.5f, 10, moving: false);
+ actions.Clear();
+ orders.Tick(world, actions);
+ Assert.That(orders.KindOf(tank), Is.EqualTo(StandingOrderKind.None), "arrived: the order ends");
+ }
+
+ [Test]
+ public void Patrol_TurnsAroundAtEachEnd()
+ {
+ var world = new FakeWorld();
+ EntityId scout = world.Add(1, 10, 10);
+ var orders = new StandingOrders();
+ orders.SetPatrol(scout, new Vector2(10, 10), new Vector2(30, 10));
+ var actions = new List();
+ orders.Tick(world, actions);
+ Assert.That(actions[0].Point, Is.EqualTo(new Vector2(30, 10)));
+
+ world.Place(scout, 29.5f, 10, moving: false);
+ actions.Clear();
+ orders.Tick(world, actions);
+ Assert.That(actions[0].Point, Is.EqualTo(new Vector2(10, 10)), "back to the start");
+ Assert.That(orders.KindOf(scout), Is.EqualTo(StandingOrderKind.Patrol), "a patrol never ends by itself");
+ }
+
+ [Test]
+ public void Move_IsNotResent_WhileTheUnitDrivesThere()
+ {
+ var world = new FakeWorld();
+ EntityId scout = world.Add(1, 10, 10);
+ var orders = new StandingOrders();
+ orders.SetPatrol(scout, new Vector2(10, 10), new Vector2(30, 10));
+ var actions = new List();
+ orders.Tick(world, actions);
+ world.Place(scout, 15, 10, moving: true);
+ actions.Clear();
+ orders.Tick(world, actions);
+ Assert.That(actions, Is.Empty, "no command spam while the order is underway");
+ }
+
+ [Test]
+ public void Guard_Engages_ThenReturnsWhenDrawnOff()
+ {
+ var world = new FakeWorld();
+ EntityId inf = world.Add(1, 10, 10);
+ var orders = new StandingOrders();
+ orders.SetGuard(inf, new Vector2(10, 10));
+ var enemy = new EntityId(9, 1);
+ world.Enemies[enemy] = new Vector2(14, 10);
+ var actions = new List();
+ orders.Tick(world, actions);
+ Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Attack));
+
+ world.Place(inf, 20, 10, moving: true); // chased too far
+ actions.Clear();
+ orders.Tick(world, actions);
+ Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Return));
+ Assert.That(actions[0].Point, Is.EqualTo(new Vector2(10, 10)));
+ }
+
+ [Test]
+ public void Guard_IgnoresEnemiesFarFromItsPost()
+ {
+ var world = new FakeWorld();
+ EntityId inf = world.Add(1, 10, 10);
+ var orders = new StandingOrders();
+ orders.SetGuard(inf, new Vector2(10, 10));
+ world.Enemies[new EntityId(9, 1)] = new Vector2(30, 10);
+ var actions = new List();
+ orders.Tick(world, actions);
+ Assert.That(actions, Is.Empty);
+ }
+
+ [Test]
+ public void DeadUnits_DropTheirOrders()
+ {
+ var world = new FakeWorld();
+ EntityId inf = world.Add(1, 10, 10);
+ var orders = new StandingOrders();
+ orders.SetGuard(inf, new Vector2(10, 10));
+ world.Own.Clear();
+ orders.Tick(world, new List());
+ Assert.That(orders.Count, Is.Zero);
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta
new file mode 100644
index 00000000..58d0f9b8
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: dcfe8497732943a5aa54a1c402ea177c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs
new file mode 100644
index 00000000..3ab5750a
--- /dev/null
+++ b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs
@@ -0,0 +1,194 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using Nova.Gameplay.Match;
+using UnityEditor;
+using UnityEngine;
+
+namespace Nova.Gameplay.Tests
+{
+ public class UnitRigAnimatorTests
+ {
+ private readonly List