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 _owned = new List(); + private Random.State _randomState; + + [SetUp] + public void SetUp() => _randomState = Random.state; + + [TearDown] + public void TearDown() + { + foreach (Object obj in _owned) Object.DestroyImmediate(obj); + _owned.Clear(); + Random.state = _randomState; + } + + private UnitRigAnimator CreateRig() + { + var root = new GameObject("TestRig"); + _owned.Add(root); + Transform body = Child(root.transform, "Body"); + Child(body, "Leg_L").localPosition = Vector3.up; + Child(body, "Spin_Test"); + Child(body, "Scan_Test"); + Child(body, "Arm_Test"); + Child(body, "Gun"); + return root.AddComponent(); + } + + private static Transform Child(Transform parent, string name) + { + var child = new GameObject(name).transform; + child.SetParent(parent, false); + return child; + } + + private UnitRigAnimator LoadArt(string category, string faction, string role, string prefix) + { + string path = $"Assets/_Project/Art/{category}/{faction}/{role}/PF_{prefix}_{faction}_{role}.prefab"; + GameObject prefab = AssetDatabase.LoadAssetAtPath(path); + if (prefab == null) Assert.Ignore($"Optional separate art bundle missing: {path}"); + var instance = Object.Instantiate(prefab); + _owned.Add(instance); + return instance.GetComponent() ?? instance.AddComponent(); + } + + private static Transform Find(UnitRigAnimator rig, string prefix) + { + foreach (Transform node in rig.GetComponentsInChildren(true)) + if (node.name.StartsWith(prefix, System.StringComparison.Ordinal)) return node; + Assert.Fail($"Expected authored animation node {prefix}"); + return null; + } + + private static void AssertContinuousSweep(UnitRigAnimator rig) + { + Transform scan = Find(rig, "Scan_"); + Quaternion rest = scan.localRotation; + Quaternion previous = rest; + float excursion = 0f; + // Cross multiple spin wraps AND a complete sweep wrap. + for (int i = 0; i < 1500; i++) + { + rig.Tick(default, 1f / 60f); + Assert.That(Quaternion.Angle(previous, scan.localRotation), Is.LessThan(0.6f), $"frame {i}"); + excursion = Mathf.Max(excursion, Quaternion.Angle(rest, scan.localRotation)); + previous = scan.localRotation; + } + Assert.That(excursion, Is.GreaterThan(45f), "A frozen dish must not pass continuity."); + } + + [Test] + public void Scan_RemainsContinuousAcrossSpinAndSweepWraps() => AssertContinuousSweep(CreateRig()); + + [Test] + public void LegionRadarPrefab_ScanRemainsContinuous() => + AssertContinuousSweep(LoadArt("Buildings", "Legion", "Radar", "BLDG")); + + [Test] + public void AllianceRadarPrefab_SpinRemainsContinuousAcrossWraps() + { + UnitRigAnimator rig = LoadArt("Buildings", "Alliance", "Radar", "BLDG"); + Transform spin = Find(rig, "Spin_"); + Quaternion previous = spin.localRotation; + for (int i = 0; i < 1500; i++) + { + rig.Tick(default, 1f / 60f); + Assert.That(Quaternion.Angle(previous, spin.localRotation), Is.InRange(0.85f, 1f), $"frame {i}"); + previous = spin.localRotation; + } + } + + private static void AssertCombatAim(UnitRigAnimator rig, bool fired) + { + Transform body = Find(rig, "Body"); + Quaternion rest = body.localRotation; + // Wait for an observable idle glance, independent of the random seed. + bool glanced = false; + for (int i = 0; i < 3600; i++) + { + rig.Tick(default, 1f / 60f); + if (Quaternion.Angle(rest, body.localRotation) > 5f) { glanced = true; break; } + } + Assert.IsTrue(glanced, "Infantry should still glance while idle."); + // Measure yaw in the root frame, including FBX axis conversion; firing pitch is allowed. + Vector3 restForward = Quaternion.Inverse(rest) * body.parent.InverseTransformDirection(rig.transform.forward); + for (int i = 0; i < (fired ? 1 : 600); i++) + { + rig.Tick(new UnitRigInput { HasTarget = !fired, Fired = fired, + TargetPosition = rig.transform.position + rig.transform.forward * 10f }, 1f / 60f); + Vector3 actual = rig.transform.InverseTransformDirection(body.TransformDirection(restForward)); + Assert.That(Mathf.Abs(Mathf.Atan2(actual.x, actual.z) * Mathf.Rad2Deg), Is.LessThan(0.05f)); + } + glanced = false; + for (int i = 0; i < 3600; i++) + { + rig.Tick(default, 1f / 60f); + Vector3 actual = rig.transform.InverseTransformDirection(body.TransformDirection(restForward)); + if (Mathf.Abs(Mathf.Atan2(actual.x, actual.z) * Mathf.Rad2Deg) > 5f) { glanced = true; break; } + } + Assert.IsTrue(glanced, "Idle glance should resume after combat."); + } + + [TestCase(false)] + [TestCase(true)] + public void Infantry_IdleLookDoesNotDeflectCombatAim(bool fired) => AssertCombatAim(CreateRig(), fired); + + [TestCase("Alliance", false)] + [TestCase("Alliance", true)] + [TestCase("Legion", false)] + [TestCase("Legion", true)] + public void InfantryPrefab_IdleLookDoesNotDeflectCombatAim(string faction, bool fired) => + AssertCombatAim(LoadArt("Units", faction, "BasicInfantry", "UNIT"), fired); + + [Test] + public void ResetPose_ReplaysFreshMotionWithoutPreviousPhasesOrLookTargets() + { + UnitRigAnimator rig = CreateRig(); + rig.ResetPose(); + Random.State replayRandom = Random.state; + Transform[] nodes = rig.GetComponentsInChildren(true); + var positions = new Vector3[nodes.Length]; + var rotations = new Quaternion[nodes.Length]; + for (int i = 0; i < 90; i++) rig.Tick(default, 1f / 60f); + for (int i = 0; i < nodes.Length; i++) + { + positions[i] = nodes[i].localPosition; + rotations[i] = nodes[i].localRotation; + } + for (int i = 0; i < 400; i++) + rig.Tick(new UnitRigInput { Speed = 2f, Working = true, Fired = true }, 1f / 60f); + rig.ResetPose(); + Random.state = replayRandom; + for (int i = 0; i < 90; i++) rig.Tick(default, 1f / 60f); + for (int i = 0; i < nodes.Length; i++) + { + Assert.That(Vector3.Distance(positions[i], nodes[i].localPosition), Is.LessThan(0.0001f), nodes[i].name); + Assert.That(Quaternion.Angle(rotations[i], nodes[i].localRotation), Is.LessThan(0.05f), nodes[i].name); + } + } + + [Test] + public void ResetPose_RestoresEmissionEvenOffscreenAndPreservesFactionTint() + { + UnitRigAnimator rig = CreateRig(); + var renderer = Find(rig, "Body").gameObject.AddComponent(); + var material = new Material(Shader.Find("Universal Render Pipeline/Lit")); + _owned.Add(material); + material.EnableKeyword("_EMISSION"); + Color emission = new Color(0.2f, 0.4f, 0.6f, 1f); + material.SetColor("_EmissionColor", emission); + renderer.sharedMaterial = material; + rig.EmissivePulse = true; + rig.ResetPose(); // Bind the authored emission before simulating a prior pulse. + var block = new MaterialPropertyBlock(); + block.SetColor("_EmissionColor", emission * 2f); + block.SetColor("_BaseColor", Color.red); + renderer.SetPropertyBlock(block); + rig.Tick(new UnitRigInput { Working = true }, 0.1f); + rig.ResetPose(); + renderer.GetPropertyBlock(block); + Assert.That(block.GetColor("_EmissionColor"), Is.EqualTo(emission)); + Assert.That(block.GetColor("_BaseColor"), Is.EqualTo(Color.red)); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta new file mode 100644 index 00000000..a66f0bd8 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d57e8340d794c7ca84d89398baf85f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs new file mode 100644 index 00000000..04b51481 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs @@ -0,0 +1,65 @@ +using NUnit.Framework; +using UnityEngine; +using Nova.Core; +using Nova.Gameplay.Match; + +namespace Nova.Gameplay.Tests +{ + /// + /// Sim heading -> Unity yaw (EditMode lane). The simulation measures + /// counter-clockwise from sim +X (), Unity + /// clockwise from world +Z; a model rotated by the view yaw must look + /// along the direction the simulation actually moves it. The regression + /// these pin down: the raw sim angle as yaw mirrored every heading + /// across the north-east diagonal. + /// + [TestFixture] + public class ViewHeadingTests + { + private static SimAngle HeadingOf(int dx, int dy) + => SimTrig.Atan2(SimFixed.FromInt(dy), SimFixed.FromInt(dx)); + + private static Vector3 WorldMove(int dx, int dy) => new Vector3(dx, 0f, dy).normalized; + + [TestCase(1, 0)] + [TestCase(0, 1)] + [TestCase(-1, 0)] + [TestCase(0, -1)] + [TestCase(1, 1)] + [TestCase(-1, 1)] + [TestCase(-1, -1)] + [TestCase(1, -1)] + [TestCase(3, -7)] + public void ModelForward_PointsAlongTheSimulatedMove(int dx, int dy) + { + Vector3 forward = ViewHeading.Rotation(HeadingOf(dx, dy)) * Vector3.forward; + + Assert.That(Vector3.Angle(forward, WorldMove(dx, dy)), Is.LessThan(0.5f), + $"a unit moving ({dx},{dy}) in sim space must face world ({dx},0,{dy})"); + } + + [Test] + public void YawDegrees_EastIsNinety_NorthIsZero() + { + Assert.That(ViewHeading.YawDegrees(HeadingOf(1, 0)), Is.EqualTo(90f).Within(0.01f)); + Assert.That(ViewHeading.YawDegrees(HeadingOf(0, 1)), Is.EqualTo(0f).Within(0.01f)); + Assert.That(ViewHeading.YawDegrees(HeadingOf(0, -1)), Is.EqualTo(180f).Within(0.01f)); + Assert.That(ViewHeading.YawDegrees(HeadingOf(-1, 0)), Is.EqualTo(270f).Within(0.01f)); + } + + [Test] + public void YawToward_MatchesTheSimHeadingOfTheSameOffset() + { + float toward = ViewHeading.YawToward(new Vector3(2f, 0f, 5f), new Vector3(6f, 3f, 2f), 0f); + + Assert.That(Mathf.DeltaAngle(toward, ViewHeading.YawDegrees(HeadingOf(4, -3))), + Is.EqualTo(0f).Within(0.05f)); + } + + [Test] + public void YawToward_CoincidentPoints_KeepsTheFallback() + { + Assert.That(ViewHeading.YawToward(Vector3.one, Vector3.one, 123f), Is.EqualTo(123f)); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta new file mode 100644 index 00000000..b3a12071 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e057d5f41bb4314ab02e06c40ae874b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 42abb371..022b57c7 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -625,6 +625,26 @@ public void SiteProgress_RequiresBuilderInReach_PausesWhenAway() "full power: exactly one Q16.16 tick of progress per tick"); } + [Test] + public void SiteProgress_SecondBuilderInReach_TakesOverFromFarLowIndexBuilder() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); + EntityId far = f.SpawnBuilder(0, 60, 60); // lowest index, far away + EntityId near = f.SpawnBuilder(0, 19, 20); // higher index, adjacent to the site + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); + + f.Step(10); + Assert.That(f.Construction.TryGetSite(UnitCommandStateView.ToRawEntityId(SiteEntity(f)), out _, out int progressRaw, out uint assigned), + Is.True); + Assert.That(progressRaw, Is.GreaterThan(0), "the adjacent second Builder builds the site"); + Assert.That(assigned, Is.EqualTo(UnitCommandStateView.ToRawEntityId(near)), + "the site is reassigned to the in-reach Builder"); + Assert.That(assigned, Is.Not.EqualTo(UnitCommandStateView.ToRawEntityId(far))); + } + [Test] public void SiteProgress_LowPower_ExactlyHalvesProgress() { diff --git a/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs b/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs index 7f60b391..bf8ebba0 100644 --- a/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs +++ b/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs @@ -107,10 +107,11 @@ public void CostFieldMutation_RegeneratesTheFlowFieldCacheInPlace() Assert.That(system.FlowFieldCacheCount, Is.EqualTo(1)); // (15,10) sits directly west of the would-be wall (x=16, - // y=10..13) on the destination row. Open terrain: the first - // strictly-lowest neighbour in iteration order is NorthEast. + // y=10..13) on the destination row. Open terrain with octile + // costs (D-116): straight east. (Before D-116 a diagonal cost the + // same as a straight step and this read NorthEast.) Assert.That(system.GetField(oldDestination).GetDirection(15, 10), - Is.EqualTo(Direction2D.NorthEast), "open-terrain baseline direction"); + Is.EqualTo(Direction2D.East), "open-terrain baseline direction"); TestHost.ApplyTerrain(system.CostField); diff --git a/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs index f8c8005b..a481baeb 100644 --- a/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs @@ -281,14 +281,18 @@ public void DiagonalMovement_IsNormalized_NoDiagonalSpeedup() [Test] public void Rotation_FollowsMovementDirection_ViaSimTrigAtan2() { - // The heading is SimTrig.Atan2 of the normalized steering vector: - // a pure diagonal move faces exactly 45 degrees (8192 angle units). + // The heading is SimTrig.Atan2 of the PATH direction, turned at + // most 30 degrees (5461 angle units) per tick (D-116): a pure + // diagonal move from heading 0 reaches exactly 45 degrees (8192) + // on the second tick. var host = TestHost.Create(Seed); EntityId unit = host.Entities.SpawnUnit(0, new Transform2D(SimFixed.FromFloat(10.5f), SimFixed.FromFloat(10.5f)), SimFixed.FromInt(5)); host.Entities.GetUnitRef(unit).SetTarget(new GridPos2D(20, 20)); host.StepTick(); + Assert.AreEqual(5461, host.Entities.GetUnitRef(unit).Transform.Rotation.RawValue, "one tick turns at most 30 degrees"); + host.StepTick(); Assert.AreEqual(8192, host.Entities.GetUnitRef(unit).Transform.Rotation.RawValue); } diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs index 1c66f4c0..27e51640 100644 --- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs @@ -100,11 +100,15 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); - Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); - Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); - Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); - Assert.That(current, Is.Not.EqualTo(revisionTwo), + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV4)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV4))); + ulong revisionThree = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3); + Assert.That(revisionThree, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); + Assert.That(revisionThree, Is.Not.EqualTo(revisionTwo), "D-104 placement and repair behavior must not share revision 2's rules identity"); + Assert.That(current, Is.Not.EqualTo(revisionThree), + "D-116 movement must not share revision 3's rules identity"); + Assert.That(current, Is.EqualTo(0x7E2E3E8C4CF1591EUL), "revision 4 canonical stream is frozen"); } [Test] diff --git a/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs b/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs index 4d319de5..aeedb5f8 100644 --- a/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs +++ b/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs @@ -224,18 +224,29 @@ public void ProducedUnits_OccupyDistinctCells() // 100 full-power build ticks per infantry, slack for the spawns. host.Step(6 * 100 + 50); - var cells = new HashSet(); - int count = 0; + // No stack: every pair of spawns stands at (near-)contact distance + // or further. Since D-116 infantry spaces at 0.3 cells each, so two + // soldiers may legitimately share one cell — distance, not cell + // identity, is the invariant. + var spawns = new List(); UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) { - ref readonly UnitState u = ref units[i]; - if (!u.IsActive || u.Role != UnitRole.BasicInfantry) continue; - count++; - Assert.That(cells.Add(CellOfTransform(in u.Transform)), Is.True, - $"infantry {i} shares its cell with another spawn — the stack is back"); + if (units[i].IsActive && units[i].Role == UnitRole.BasicInfantry) spawns.Add(i); + } + Assert.That(spawns.Count, Is.EqualTo(5), "all five queued infantry spawned"); + for (int a = 0; a < spawns.Count; a++) + { + for (int b = a + 1; b < spawns.Count; b++) + { + ref readonly UnitState ua = ref units[spawns[a]]; + ref readonly UnitState ub = ref units[spawns[b]]; + SimFixed settled = (MovementSpacing.RadiusOf(in ua) + MovementSpacing.RadiusOf(in ub)) + * SimFixed.FromRaw(SimFixed.OneRaw / 8 * 7); + Assert.That(ua.Transform.DistanceToSquared(in ub.Transform), Is.GreaterThanOrEqualTo(settled * settled), + $"infantry {spawns[a]} and {spawns[b]} stack — the spawn stack is back"); + } } - Assert.That(count, Is.EqualTo(5), "all five queued infantry spawned"); } // -------------------------------------------------------------- @@ -304,8 +315,9 @@ public void StandingSeparation_ExactOverlap_UsesTheIndexTieBreak() Assert.That(a.Transform.PositionX != b.Transform.PositionX || a.Transform.PositionY != b.Transform.PositionY, Is.True, "exactly stacked units must separate"); - // Same dead-zone residual as the offset case: 7/8 of contact. - SimFixed minDist = a.Radius + b.Radius; + // Same dead-zone residual as the offset case: 7/8 of contact, + // measured with the per-role spacing radius (D-116). + SimFixed minDist = MovementSpacing.RadiusOf(in a) + MovementSpacing.RadiusOf(in b); SimFixed settled = minDist * SimFixed.FromRaw(SimFixed.OneRaw / 8 * 7); Assert.That(a.Transform.DistanceToSquared(in b.Transform), Is.GreaterThanOrEqualTo(settled * settled), @@ -406,6 +418,140 @@ public void Sell_FreesTheFootprintInTheCostField() "selling frees the footprint cells in the cost field"); } + // -------------------------------------------------------------- + // D-116: make way, heading, size-aware slots + // -------------------------------------------------------------- + + private static EntityId SpawnRole(Host host, UnitRole role, SimFixed x, SimFixed y, byte slot = 0) + { + SimDefinitions.TryGetUnit(slot == 0 ? FactionId.Alliance : FactionId.Legion, role, out SimUnitDefinition def); + return host.Entities.SpawnUnit(slot, new Transform2D(x, y), def.MoveSpeed, maxHealth: def.MaxHealth, role: def.Role); + } + + private static SimFixed Half(int cells) => SimFixed.FromInt(cells) + SimFixed.FromRaw(SimFixed.OneRaw / 2); + + [Test] + public void StandingInfantry_MakesWay_ForADrivingTank() + { + Host host = Host.Create(); + // Tank drives east along y = 30.5; a soldier stands right on its line. + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + EntityId soldier = SpawnRole(host, UnitRole.BasicInfantry, Half(26), Half(30)); + host.SubmitMove(new[] { host.Raw(tank) }, 34, 30); + + SimFixed maxTankDrift = SimFixed.Zero; + for (int t = 0; t < 60; t++) + { + host.StepTick(); + SimFixed drift = host.Entities.GetUnitRef(tank).Transform.PositionY - Half(30); + if (drift < SimFixed.Zero) drift = -drift; + if (drift > maxTankDrift) maxTankDrift = drift; + } + + ref readonly UnitState s = ref host.Entities.GetUnitRef(soldier); + ref readonly UnitState k = ref host.Entities.GetUnitRef(tank); + SimFixed sideStep = s.Transform.PositionY - Half(30); + if (sideStep < SimFixed.Zero) sideStep = -sideStep; + Assert.That(sideStep, Is.GreaterThan(MovementSpacing.RadiusOf(in k)), + "the soldier must have stepped off the tank's line"); + Assert.That(maxTankDrift, Is.LessThan(SimFixed.FromRaw(SimFixed.OneRaw / 4)), + "the tank keeps its line through the group instead of being pushed aside"); + Assert.That(SimFixed.WorldToGrid(k.Transform.PositionX), Is.EqualTo(34), "the tank arrived"); + } + + [Test] + public void FlowField_OpenGround_LeadsStraight_NotDiagonalFirst() + { + // Octile costs (D-116): east of the target on open ground the flow + // points straight east, not into a diagonal staircase. + Host host = Host.Create(); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + host.SubmitMove(new[] { host.Raw(tank) }, 34, 30); + host.Step(3); + FlowField f = host.Pathfinding.GetField(host.Entities.GetUnitRef(tank).TargetGridPos); + Assert.That(f, Is.Not.Null); + for (int x = 18; x < 34; x++) + { + Assert.That(f.GetDirection((ushort)x, 30), Is.EqualTo(Direction2D.East), $"cell ({x},30)"); + } + Assert.That(f.GetDirection(30, 33), Is.EqualTo(Direction2D.SouthEast), "off the row, diagonal first while it saves distance"); + } + + [Test] + public void StandingTank_DoesNotYield_ToInfantry() + { + Host host = Host.Create(); + EntityId soldier = SpawnRole(host, UnitRole.BasicInfantry, Half(20), Half(30)); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(25), Half(30)); + host.SubmitMove(new[] { host.Raw(soldier) }, 32, 30); + host.Step(5); + ref readonly UnitState k = ref host.Entities.GetUnitRef(tank); + Assert.That(k.Transform.PositionY, Is.EqualTo(Half(30)), "a tank does not step aside for a soldier"); + Assert.That(k.Transform.PositionX, Is.EqualTo(Half(25))); + } + + [Test] + public void EnemyUnits_DoNotYield() + { + Host host = Host.Create(); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + EntityId enemy = SpawnRole(host, UnitRole.BasicInfantry, Half(23), Half(31), slot: 1); + host.SubmitMove(new[] { host.Raw(tank) }, 30, 30); + host.Step(3); + ref readonly UnitState e = ref host.Entities.GetUnitRef(enemy); + Assert.That(e.Transform.PositionY, Is.EqualTo(Half(31)), "make-way is for friends only"); + } + + [Test] + public void Heading_IgnoresSeparation_InACrowd() + { + Host host = Host.Create(); + // A soldier walks east straight through a block of standing soldiers. + var crowd = new List(); + for (int y = 28; y <= 32; y++) + { + for (int x = 24; x <= 27; x++) crowd.Add(SpawnRole(host, UnitRole.BasicInfantry, Half(x), Half(y))); + } + EntityId walker = SpawnRole(host, UnitRole.BasicInfantry, Half(18), Half(30)); + host.SubmitMove(new[] { host.Raw(walker) }, 36, 30); + host.Step(3); + + for (int t = 0; t < 50; t++) + { + host.StepTick(); + ref readonly UnitState w = ref host.Entities.GetUnitRef(walker); + if (!w.IsMoving) break; + int raw = w.Transform.Rotation.RawValue; + int offEast = Math.Min(raw, 65536 - raw); + Assert.That(offEast, Is.LessThanOrEqualTo(8192 + 5461), + $"tick {t}: heading swung {offEast * 360.0 / 65536:F0} deg off the path — separation is steering the heading again"); + } + } + + [Test] + public void GroupMove_LargeUnits_ClaimSlotsWithAFreeRing() + { + Host host = Host.Create(); + var tanks = new List(); + for (int i = 0; i < 6; i++) tanks.Add(SpawnRole(host, UnitRole.BattleTank, Half(10 + i * 2), Half(10))); + var raws = new uint[tanks.Count]; + for (int i = 0; i < tanks.Count; i++) raws[i] = host.Raw(tanks[i]); + host.SubmitMove(raws, 40, 40); + host.StepTick(); + host.StepTick(); + + for (int a = 0; a < tanks.Count; a++) + { + GridPos2D ga = host.Entities.GetUnitRef(tanks[a]).GoalGridPos; + for (int b = a + 1; b < tanks.Count; b++) + { + GridPos2D gb = host.Entities.GetUnitRef(tanks[b]).GoalGridPos; + int cheb = Math.Max(Math.Abs(ga.X - gb.X), Math.Abs(ga.Y - gb.Y)); + Assert.That(cheb, Is.GreaterThanOrEqualTo(2), $"tank slots {ga} and {gb} are adjacent"); + } + } + } + private static GridPos2D CellOfTransform(in Transform2D transform) { return new GridPos2D( diff --git a/Assets/Tests/PlayMode/ArtShowcaseTests.cs b/Assets/Tests/PlayMode/ArtShowcaseTests.cs new file mode 100644 index 00000000..9714ca29 --- /dev/null +++ b/Assets/Tests/PlayMode/ArtShowcaseTests.cs @@ -0,0 +1,192 @@ +using System.Collections; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + /// + /// Evidence shots of the whole art set as the player sees it: every + /// building and unit of both factions, placed in the running match at + /// its real in-game scale, photographed through the main camera (and so + /// with the project's post-processing) from three RTS camera heights. + /// It exists because close-up previews (ArtPreviewCapture) say nothing + /// about readability at 30-40 m camera height, which is where an art + /// pass is won or lost. + /// + /// The assertions only guard that every placed entity owns a view; the + /// value is in output/demo/showcase_*.png. Headless with graphics, never + /// with -quit (see GrayboxDemoProofTests). + /// + /// + public sealed class ArtShowcaseTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const string ShotDir = "output/demo"; + + private static readonly UnitRole[] Buildings = + { + UnitRole.HQ, UnitRole.Power, UnitRole.Refinery, UnitRole.Storage, UnitRole.Barracks, + UnitRole.VehicleFactory, UnitRole.ResearchLab, UnitRole.Radar, UnitRole.DefensePlatform, + }; + + private static readonly UnitRole[] Units = + { + UnitRole.Builder, UnitRole.Harvester, UnitRole.BasicInfantry, UnitRole.AntiArmorInfantry, + UnitRole.ScoutVehicle, UnitRole.LightTank, UnitRole.BattleTank, UnitRole.Artillery, + }; + + [UnityTest] + public IEnumerator Showcase_BothFactionsAtRtsDistance() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap"); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady, "the canonical opening position was not built"); + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views, "Bootstrap scene contains no UnitViewManager"); + + // Legion sits in the fog of the local Alliance player; the editor + // reveal is the same switch the lab uses to watch the AI play. + if (!FogRevealDebug.RevealAll) FogRevealDebug.Toggle(); + try + { + var placed = new List(); + Vector3 allianceCentre = Lay(runner, 0, 22, 6, +1, placed); + Vector3 legionCentre = Lay(runner, 1, 102, 118, -1, placed); + + // Let views spawn, rigs settle and the glow pulse run. + yield return new WaitForSeconds(2.5f); + + foreach (EntityId id in placed) + { + Assert.IsTrue(views.TryGetView(id, out GameObject view) && view != null, + $"entity {id} owns no view — prefab resolution or fog feed broke"); + } + + foreach ((string name, Vector3 centre) in new[] { ("alliance", allianceCentre), ("legion", legionCentre) }) + { + foreach (float height in new[] { 18f, 30f, 42f }) + { + CaptureRts($"{ShotDir}/showcase_{name}_{height:0}m.png", centre, height); + } + } + } + finally + { + if (FogRevealDebug.RevealAll) FogRevealDebug.Toggle(); + } + } + + /// + /// Places all nine buildings (completed) in a 5-wide row grid and the + /// eight units in a row in front of them, walking from the start cell + /// in direction (+1 east/north, -1 west/south). + /// Returns the world centre of the layout. + /// + private static Vector3 Lay(MatchRunner runner, byte slot, int x0, int y0, int dir, List placed) + { + FactionId faction = runner.Economy.GetSlotFaction(slot); + float minX = float.MaxValue, maxX = float.MinValue, minY = float.MaxValue, maxY = float.MinValue; + + for (int i = 0; i < Buildings.Length; i++) + { + Assert.IsTrue(SimDefinitions.TryGetBuilding(faction, Buildings[i], out SimBuildingDefinition def), + $"setup: no {faction} {Buildings[i]} definition"); + int ox = x0 + dir * (i % 5) * 5; + int oy = y0 + dir * (i / 5) * 5; + EntityId id = PlaceNear(runner, slot, def.DefinitionId, ox, oy); + Assert.IsTrue(id.IsValid, $"setup: {faction} {Buildings[i]} could not be placed near ({ox},{oy})"); + placed.Add(id); + Grow(ref minX, ref maxX, ref minY, ref maxY, ox + 1.5f, oy + 1.5f); + } + + int uy = y0 + dir * 11; + for (int i = 0; i < Units.Length; i++) + { + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, Units[i], out SimUnitDefinition def), + $"setup: no {faction} {Units[i]} definition"); + int ux = x0 + dir * i * 3; + EntityId id = runner.Entities.SpawnUnit(slot, + new Transform2D(SimFixed.FromInt(ux), SimFixed.FromInt(uy)), def.MoveSpeed, role: def.Role); + Assert.IsTrue(id.IsValid, $"setup: spawn of {faction} {Units[i]} failed"); + placed.Add(id); + Grow(ref minX, ref maxX, ref minY, ref maxY, ux, uy); + } + + return new Vector3((minX + maxX) * 0.5f, 0f, (minY + maxY) * 0.5f); + } + + private static EntityId PlaceNear(MatchRunner runner, byte slot, ushort defId, int x, int y) + { + for (int r = 0; r < 4; r++) + { + for (int dy = -r; dy <= r; dy++) + { + for (int dx = -r; dx <= r; dx++) + { + if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; + EntityId id = runner.Construction.PlaceCompletedBuilding(slot, defId, x + dx, y + dy); + if (id.IsValid) return id; + } + } + } + return EntityId.Invalid; + } + + private static void Grow(ref float minX, ref float maxX, ref float minY, ref float maxY, float x, float y) + { + minX = Mathf.Min(minX, x); maxX = Mathf.Max(maxX, x); + minY = Mathf.Min(minY, y); maxY = Mathf.Max(maxY, y); + } + + /// + /// The RTS camera's 55-degree pitch at a given height, rendered through + /// the scene's main camera (moved, then put back) so post-processing + /// is included. + /// + private static void CaptureRts(string path, Vector3 focus, float height) + { + Camera camera = Camera.main; + Assert.NotNull(camera, "no main camera in the Bootstrap scene"); + Transform t = camera.transform; + Vector3 oldPos = t.position; + Quaternion oldRot = t.rotation; + var rt = new RenderTexture(1600, 900, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; + try + { + Quaternion rot = Quaternion.Euler(55f, 0f, 0f); + float distance = height / Mathf.Sin(55f * Mathf.Deg2Rad); + t.SetPositionAndRotation(focus - rot * Vector3.forward * distance, rot); + camera.targetTexture = rt; + camera.Render(); + RenderTexture.active = rt; + var tex = new Texture2D(1600, 900, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0f, 0f, 1600f, 900f), 0, 0); + tex.Apply(); + File.WriteAllBytes(path, tex.EncodeToPNG()); + RenderTexture.active = null; + Object.DestroyImmediate(tex); + } + finally + { + camera.targetTexture = null; + t.SetPositionAndRotation(oldPos, oldRot); + rt.Release(); + Object.DestroyImmediate(rt); + } + } + } +} diff --git a/Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta b/Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta new file mode 100644 index 00000000..d6307e60 --- /dev/null +++ b/Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 921b12a4edf84de09e8f513a5dfca76a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs new file mode 100644 index 00000000..83ce5768 --- /dev/null +++ b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs @@ -0,0 +1,95 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.CommandsV1; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + public sealed class BuilderWorkPresentationTests + { + [UnityTest] + public IEnumerator BuilderArms_WorkAtSite_AndRestWhenStoppedOutsideReach() + { + yield return SceneManager.LoadSceneAsync("Assets/_Project/Scenes/Bootstrap.unity", LoadSceneMode.Single); + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady); + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views); + + EntityId builder = EntityId.Invalid; + foreach (UnitState unit in runner.Entities.RawUnits) + if (unit.IsActive && unit.PlayerId == MatchBootstrap.LocalSlot && unit.Role == UnitRole.Builder) + { builder = unit.Id; break; } + Assert.IsTrue(builder.IsValid, "canonical opening must contain an own builder"); + // Fixture position beside the canonical refinery footprint (8,4)..(10,6). + // Subsequent placement, movement and Stop use the real command intake. + runner.Entities.GetUnitRef(builder).Transform = new Transform2D(SimFixed.FromInt(7), SimFixed.FromInt(5)); + yield return new WaitForSeconds(0.4f); + Assert.IsTrue(views.TryGetView(builder, out GameObject view) && view != null); + Assert.NotNull(view.GetComponent(), "requires the current art bundle"); + Transform arm = null; + foreach (Transform node in view.GetComponentsInChildren(true)) + if (node.name.StartsWith("Arm_", System.StringComparison.Ordinal)) { arm = node; break; } + Assert.NotNull(arm, "builder prefab needs an authored Arm_ node"); + Quaternion rest = arm.localRotation; + uint rawBuilder = UnitCommandStateView.ToRawEntityId(builder); + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new PlaceBuildingPayload(4, 8, 4)), out _)); + + float maxWorkAngle = 0f; + float until = Time.time + 1.8f; + while (Time.time < until) + { + yield return null; + maxWorkAngle = Mathf.Max(maxWorkAngle, Quaternion.Angle(rest, arm.localRotation)); + } + uint siteRaw = 0; + foreach (UnitState unit in runner.Entities.RawUnits) + if (unit.IsActive && unit.PlayerId == MatchBootstrap.LocalSlot + && runner.Construction.IsActiveSite(unit.Id)) + { siteRaw = UnitCommandStateView.ToRawEntityId(unit.Id); break; } + Assert.AreNotEqual(0u, siteRaw, "placement must create a real construction site"); + Assert.IsTrue(runner.Construction.TryGetSite(siteRaw, out _, out int nearProgress, out uint assigned)); + Assert.AreEqual(rawBuilder, assigned); + Assert.Greater(nearProgress, 0, "in-reach builder must progress construction"); + Assert.Greater(maxWorkAngle, 3f, "working builder's authored arm must visibly swing"); + + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new MovePayload(new[] { rawBuilder }, + SimFixed.FromInt(7), SimFixed.FromInt(12))), out _)); + until = Time.time + 10f; + while (Time.time < until + && SimFixed.WorldToGrid(runner.Entities.GetUnitRef(builder).Transform.PositionY) < 9) + yield return null; + Assert.GreaterOrEqual(SimFixed.WorldToGrid(runner.Entities.GetUnitRef(builder).Transform.PositionY), 9, + "builder must actually leave footprint reach through movement"); + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new StopPayload(new[] { rawBuilder })), out _)); + yield return new WaitForSeconds(0.9f); // command latency and arm-blend settling + Assert.IsFalse(runner.Entities.GetUnitRef(builder).IsMoving, "Stop must land before checking idle work"); + Assert.IsTrue(runner.Construction.TryGetSite(siteRaw, out _, out int farProgress, out assigned)); + Assert.AreEqual(rawBuilder, assigned, "assignment intentionally survives departure"); + uint tickBefore = runner.Session.CurrentTick; + float maxIdleAngle = 0f; + until = Time.time + 1.2f; + while (Time.time < until) + { + yield return null; + maxIdleAngle = Mathf.Max(maxIdleAngle, Quaternion.Angle(rest, arm.localRotation)); + } + Assert.Greater(runner.Session.CurrentTick, tickBefore, "the match must keep ticking during the pause check"); + Assert.IsTrue(runner.Construction.TryGetSite(siteRaw, out _, out int finalProgress, out _)); + Assert.AreEqual(farProgress, finalProgress, "out-of-reach builder must not advance the site"); + Assert.Less(maxIdleAngle, 0.1f, "assigned but out-of-reach builder must not fake construction with its arms"); + } + } +} diff --git a/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta new file mode 100644 index 00000000..07d06b8f --- /dev/null +++ b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 44d55401fb944e5099777f33d7f6099f diff --git a/Assets/Tests/PlayMode/CommandBarTests.cs b/Assets/Tests/PlayMode/CommandBarTests.cs new file mode 100644 index 00000000..6aa42b37 --- /dev/null +++ b/Assets/Tests/PlayMode/CommandBarTests.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using UnityEngine.UIElements; +using Nova.Core; +using Nova.Gameplay; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; +using Object = UnityEngine.Object; + +namespace Nova.PlayMode.Tests +{ + /// + /// The unit command bar and its group orders in the running match (D-117). + /// Presentation types are reached by reflection — the test assemblies may + /// not reference the presentation layer (quality/scripts/run_gate_check.py), + /// the same arrangement MainMenuTests use. The match is started through the + /// menu's own StartMatch, so the cockpit is exactly what a player sees. + /// Evidence: output/demo/commandbar_*.png (world plus UI composited). + /// + public sealed class CommandBarTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const string ShotDir = "output/demo"; + + [UnityTest] + public IEnumerator SelectedTanks_GetTheBar_AndPatrolInLine() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + yield return null; + + Component menu = FindByTypeName("MainMenuController"); + Assert.NotNull(menu, "no MainMenuController in the Bootstrap scene"); + Invoke(menu, "StartMatch"); + yield return null; + + var bootstrap = Object.FindAnyObjectByType(); + Assert.IsTrue(bootstrap.IsMatchReady, "the menu's StartMatch did not build a match"); + MatchRunner runner = bootstrap.Runner; + Component input = FindByTypeName("RtsDeviceInput"); + Assert.NotNull(input); + FactionId faction = runner.Economy.GetSlotFaction(0); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.LightTank, out SimUnitDefinition tank)); + + var ids = new List(); + for (int i = 0; i < 6; i++) + { + ids.Add(runner.Entities.SpawnUnit(0, + new Transform2D(SimFixed.FromInt(24 + (i % 3) * 2), SimFixed.FromInt(20 + (i / 3) * 2)), + tank.MoveSpeed, role: tank.Role)); + } + yield return new WaitForSeconds(0.5f); + + var selection = (SelectionManager)Get(input, "Selection"); + selection.ReplaceSelection(ids.ToArray()); + yield return null; + yield return null; + + Type barType = FindType("Nova.Presentation.UI.UnitCommandBar"); + Assert.NotNull(barType, "UnitCommandBar type missing"); + Assert.IsTrue((bool)barType.GetProperty("ShowsUnits").GetValue(null), "the command bar must show for selected units"); + + Invoke(input, "SetFormation", FormationKind.Line); + Vector3 patrolEnd = new Vector3(44f, 0f, 22f); + Invoke(input, "OrderPatrolAt", patrolEnd); + + yield return new WaitForSeconds(1.2f); + yield return CaptureWithUi($"{ShotDir}/commandbar_patrol.png", new Vector3(32f, 0f, 21f), 16f); + + // Out to the far end, then back: the patrol turns around on its own. + float maxX = float.MinValue; + bool turned = false; + float deadline = Time.time + 25f; + while (Time.time < deadline && !turned) + { + yield return new WaitForSeconds(0.25f); + float x = MeanX(runner, ids); + if (x > maxX) maxX = x; + if (maxX > 41f && x < maxX - 2f) turned = true; + } + Assert.IsTrue(turned, $"the patrol never turned around (max mean x {maxX:F1})"); + + // A line facing east stands north-south at the far end. + float minY = float.MaxValue, maxY = float.MinValue; + foreach (EntityId id in ids) + { + runner.Entities.TryGetUnit(id, out UnitState u); + minY = Mathf.Min(minY, u.Transform.PositionY.ToFloat()); + maxY = Mathf.Max(maxY, u.Transform.PositionY.ToFloat()); + } + Assert.That(maxY - minY, Is.GreaterThan(3f), "the line spreads across the direction of travel"); + Debug.Log($"[CommandBar] patrol turned at mean x {maxX:F1}, line spread {maxY - minY:F1}"); + } + + [UnityTest] + public IEnumerator SelectedHarvesters_ShowCargoReadout_AndNoCombatOrders() + { + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + yield return null; + + Component menu = FindByTypeName("MainMenuController"); + Assert.NotNull(menu, "no MainMenuController in the Bootstrap scene"); + Invoke(menu, "StartMatch"); + yield return null; + + var bootstrap = Object.FindAnyObjectByType(); + Assert.IsTrue(bootstrap.IsMatchReady); + MatchRunner runner = bootstrap.Runner; + Component input = FindByTypeName("RtsDeviceInput"); + Assert.NotNull(input); + FactionId faction = runner.Economy.GetSlotFaction(0); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.Harvester, out SimUnitDefinition harvester)); + + var ids = new List(); + for (int i = 0; i < 2; i++) + { + EntityId id = runner.Entities.SpawnUnit(0, + new Transform2D(SimFixed.FromInt(24 + i * 2), SimFixed.FromInt(20)), + harvester.MoveSpeed, role: harvester.Role); + runner.Entities.GetUnitRef(id).CargoAE = 100; + ids.Add(id); + } + yield return new WaitForSeconds(0.3f); + + var selection = (SelectionManager)Get(input, "Selection"); + selection.ReplaceSelection(ids.ToArray()); + yield return null; + yield return null; + + Component bar = FindByTypeName("UnitCommandBar"); + Assert.NotNull(bar, "UnitCommandBar missing"); + var document = (UIDocument)bar.GetType().GetField("_document", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(bar); + VisualElement root = document.rootVisualElement; + + foreach (string hidden in new[] { "Patrol", "Guard", "AttackMove", "AttackTarget", "Scatter", "Loose", "Line", "Column", "Wedge", "Block", "Repair" }) + { + VisualElement button = root.Q($"cmd-{hidden}"); + Assert.NotNull(button, $"cmd-{hidden} missing"); + Assert.AreEqual(DisplayStyle.None, button.style.display.value, $"{hidden} must be hidden for harvesters"); + } + foreach (string shown in new[] { "Move", "Stop", "Harvest", "Deliver" }) + { + VisualElement button = root.Q($"cmd-{shown}"); + Assert.NotNull(button, $"cmd-{shown} missing"); + Assert.AreNotEqual(DisplayStyle.None, button.style.display.value, $"{shown} must stay visible for harvesters"); + } + + VisualElement cargo = root.Q("cargo-panel"); + Assert.NotNull(cargo, "cargo panel missing"); + Assert.AreEqual(DisplayStyle.Flex, cargo.style.display.value, "harvester selection must show the cargo readout"); + int capacity = SimDefinitions.HarvesterCargoCapacityAE(faction); + Assert.AreEqual($"Ladung 200 / {2 * capacity}", root.Q