From df61900db80158d4e733b65c8cb3274b7b8f745c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 13:44:22 +0200 Subject: [PATCH] Rewrite the tests that read the mixer; add the ship-level allocator tests EvasionTerm: the aggregate reads go; SteeringTests: ATurnIsThatFractionOfTheHullsTurn; Brownout/ConditionRatio seat the lone thruster on the centre column; ThrottleLockTests compare a locked ship to an unlocked twin through the same ticks; ThrustAllocationTests (Duel flight, lost/degraded/repaired drives, dead thrusters, full turn, arena-stepped flight); ThrustAllocatorTests.AMicroIntentIsServed. Not yet verified green at this commit; the test project builds only against the deleted aggregates' removal in the previous commit. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 5 +- .../ConditionRatioTests.cs | 5 +- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 69 +----- tests/Aetheria.Shared.Tests/SimClockTests.cs | 2 +- tests/Aetheria.Shared.Tests/SteeringTests.cs | 20 +- .../ThrottleLockTests.cs | 184 ++++++++------- .../ThrustAllocationTests.cs | 212 ++++++++++++++++++ .../ThrustAllocatorTests.cs | 27 +++ 8 files changed, 363 insertions(+), 161 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index b4a691574..b7a13fdc2 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -109,7 +109,10 @@ public sealed class BrownoutTests : IDisposable var reactorData = cache.GetByName("Reactor"); ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); - Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")))); + // The Skiff's centre column: a thruster there has no torque, so the allocator can fire it alone. These tests pin power, + // not flight, and a lone off-centre thruster could not fire without turning. + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 0))); + Assert.True(ship.GetBehavior().Torque == 0f, "the lone thruster turns nothing"); zone.Entities.Add(ship); // Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 13b5f281e..77b51156a 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -92,7 +92,10 @@ public sealed class ConditionRatioTests : IDisposable var reactorData = cache.GetByName("Reactor"); ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); - Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")))); + // The Skiff's centre column: a thruster there has no torque, so the allocator can fire it alone. These tests pin power, + // not flight, and a lone off-centre thruster could not fire without turning. + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 0))); + Assert.True(ship.GetBehavior().Torque == 0f, "the lone thruster turns nothing"); zone.Entities.Add(ship); ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 9723cad90..a0c823e6b 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -712,17 +712,14 @@ public sealed partial class RunStartTests } } - // Finding thruster-thrust-cache: Ship's aggregates read what the thrusters push with now, not what they pushed - // with at construction. The idle Djinni's forward thrust, in newtons, is its envelope's forward acceleration - // times its mass,. + // Finding thruster-thrust-cache: each thruster reads what it pushes with now, not what it pushed with at + // construction. [Fact] - public void TheAggregatesReadLiveThrust() + public void TheThrustersReadLiveThrust() { var (_, _, djinni) = EvFleet(); EvSettle(djinni); djinni.Update(EvDt); - Assert.InRange(djinni.ForwardThrust, djinni.Envelope.Forward * djinni.Mass * .99f, djinni.Envelope.Forward * djinni.Mass * 1.01f); - Assert.True(djinni.ForwardThrust > 0f); var thrusters = djinni.GetBehaviors().ToList(); Assert.NotEmpty(thrusters); foreach (var thruster in thrusters) @@ -1010,64 +1007,4 @@ public sealed partial class RunStartTests Assert.True(first >= ticks - 1 && first <= ticks + 1, $"window {window}, held {held}: forgot at tick {first}, expected about {ticks}"); } } - - // Finding strafe-readers-unpinned: every one of Ship's thrust aggregates and the strafe torque compensation reads the - // live thrust, not the stat minimum a thruster had at construction. The idle Djinni's aggregates are the sums of what - // its thrusters push with now, group by group. - [Fact] - public void EveryAggregateAndTheCompensationReadLiveThrust() - { - var (_, _, djinni) = EvFleet(); - EvSettle(djinni); - djinni.Update(EvDt); - var thrusters = djinni.GetBehaviors().Where(t => t.Item.Active.Value).ToList(); - float Live(Thruster t) => t.Evaluate(((ThrusterData) t.Data).Thrust); - float Mount(ItemRotation rotation, Func value) => thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).Sum(value); - foreach (var rotation in new[] { ItemRotation.None, ItemRotation.Clockwise, ItemRotation.CounterClockwise }) - { - var group = thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).ToList(); - Assert.NotEmpty(group); - Assert.All(group, t => Assert.True(Live(t) > ((ThrusterData) t.Data).Thrust.Min * 1.1f, $"fixture: {rotation} thruster thrust {Live(t)} above its stat minimum")); - } - - void Near(float expected, float actual) => Assert.InRange(actual, expected - .001f * Math.Abs(expected) - 1e-3f, expected + .001f * Math.Abs(expected) + 1e-3f); - Near(Mount(ItemRotation.None, Live), djinni.ReverseThrust); - Near(Mount(ItemRotation.Clockwise, Live), djinni.LeftStrafeThrust); - Near(Mount(ItemRotation.CounterClockwise, Live), djinni.RightStrafeThrust); - Near(Mount(ItemRotation.Clockwise, t => t.Torque * Live(t)), djinni.LeftStrafeTotalTorque); - Near(Mount(ItemRotation.CounterClockwise, t => t.Torque * Live(t)), djinni.RightStrafeTotalTorque); - - // The compensation trims each torque thruster of a strafing side by the side's mean torque, over that thruster's - // own torque at its live thrust. - foreach (var (side, direction, total) in new[] - { - (ItemRotation.CounterClockwise, 1f, djinni.RightStrafeTotalTorque), - (ItemRotation.Clockwise, -1f, djinni.LeftStrafeTotalTorque) - }) - { - // Which thrusters take the trim is the rule under test, so it is read from the thrusters' own torques and not from - // Ship's private list (finding compensation-membership-unpinned): the side's thrusters that turn the way the side's - // total torque turns. The fixture must have thrusters on both sides of that line, or the rule is not exercised. - var onSide = djinni.GetBehaviors().Where(t => t.Item.EquippableItem.Rotation == side).ToList(); - var members = onSide.Where(t => Math.Sign(t.Torque) == Math.Sign(total)).ToList(); - Assert.NotEmpty(members); - Assert.True(members.Count < onSide.Count, $"fixture: the {side} side has a thruster that turns against its total torque, so membership is exercised"); - djinni.MovementDirection = float2(direction, 0); - EvFace(djinni, float3(0, 0, 1)); - djinni.Update(EvDt); - var mean = Math.Abs(total) / members.Count; - var trimmed = 0; - foreach (var thruster in thrusters.Where(t => t.Item.EquippableItem.Rotation == side)) - { - var expected = members.Contains(thruster) ? 1f - mean / (Math.Abs(thruster.Torque) * Live(thruster)) : 1f; - expected = Math.Max(0f, Math.Min(1f, expected)); - if (expected < .99f && expected > .01f) trimmed++; - Assert.InRange(thruster.Axis, expected - .002f, expected + .002f); - } - Assert.True(trimmed > 0, $"fixture: the {side} compensation trims a thruster to somewhere between off and full"); - djinni.MovementDirection = float2(0, 0); - EvSettle(djinni); - djinni.Update(EvDt); - } - } } diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index ade676bfc..a87a39702 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -239,7 +239,7 @@ public sealed partial class RunStartTests // A write to Direction between steps (staging, a load, a wormhole exit) is not a turn. player.Turn = 0; - player.MovementDirection = float2(0, 0); // two backfilled drives differ in quality, so a held thrust is a net torque + player.MovementDirection = float2(0, 0); // the drives are idled so the Direction write below is tested alone for (var step = 0; step < 600 && abs(player.TurnRate) > 0; step++) arena.Update(ClockFrame); player.Direction = normalize(float2(3, -4)); arena.Update(ClockFrame); diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index e6c8604a6..60861ecdb 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -240,7 +240,7 @@ public sealed class SteeringTests Assert.Equal(-sqrt(sin(radians(80f))), Steering.Toward(ship, Heading(ship, -80f)), 4); } - // The turn bound has one owner, the actuators: Ship.Update hands Turn to them unclamped, Thruster.Axis saturates to + // The turn bound has one owner, the actuators: the allocator clamps the intent to -1..1 and Thruster.Axis saturates to // 0..1. A Turn of 3 therefore rotates the hull as a Turn of 1. [Fact] public void TheActuatorsBoundATurnBeyondOne() @@ -278,15 +278,16 @@ public sealed class SteeringTests } } - // The size of a turn, computed from the hull and not read back from the code under test: each rotation thruster - // at Axis 1 turns the hull by Torque * Thrust * TorqueMultiplier / Mass * dt per tick (Thruster.Execute's law, - // summed here from the thrusters' own stats over the sets whose Torque clears the floor). + // The size of a turn, computed from the hull and not read back from the code under test: Turn is that fraction of + // the hull's whole turn in the direction asked (turn-authority-full). The whole turn is every thruster firing at + // full grant on the side it turns toward, Torque * Thrust * TorqueMultiplier / Mass per thruster, with no floor + // (Thruster.Execute's law, summed here from the thrusters' own stats). [Theory] [InlineData(1f)] [InlineData(-1f)] [InlineData(.5f)] [InlineData(-.5f)] - public void ATurnRotatesAtTheRateTheHullsTorqueGives(float turn) + public void ATurnIsThatFractionOfTheHullsTurn(float turn) { using var cache = RestoredHullsTests.OpenCatalog(); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); @@ -299,16 +300,15 @@ public sealed class SteeringTests ship.Turn = turn; ship.Update(dt); - float Rate(Func set, float axis) => - thrusters.Where(set).Sum(t => axis * t.Torque * t.Thrust * settings.TorqueMultiplier / ship.Mass * dt); - var clockwise = Rate(t => t.Torque > settings.TorqueFloor, max(turn, 0f)); - var counter = Rate(t => t.Torque < -settings.TorqueFloor, max(-turn, 0f)); - var expected = clockwise + counter; + float Whole(Func side) => + thrusters.Select(t => t.Torque * t.NominalThrust * settings.TorqueMultiplier / ship.Mass * dt).Where(side).Sum(abs); + var expected = turn * (turn > 0 ? Whole(r => r > 0) : Whole(r => r < 0)); var after = normalize(ship.Direction); var turned = atan2(dot(after, right), dot(after, nose)); // positive is clockwise Assert.True(abs(expected) > radians(.01f), $"the expected turn is real: {expected}"); Assert.InRange(abs(turned / expected), .98f, 1.02f); + Assert.True(turned * turn > 0f, "the turn goes the way it was asked"); } // A state that writes a constant turn, standing for any piloting state. diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 3f6109155..b51a9086a 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -8,8 +8,10 @@ using Xunit; using static CultMath.math; // A ThrottleLock on an active consumable replaces the pilot's movement intent with full forward, read once at -// the top of Ship.Update's active branch. Turn is untouched. Every observation is a thruster's Axis after a real -// Ship.Update, with the effect started through Entity.ActivateConsumable. +// the top of Ship.Update's active branch; the allocator is the only thing that turns intent into throttles. Turn is +// untouched. Every observation is a thruster's Axis after a real Ship.Update, with the effect started through +// Entity.ActivateConsumable. A lock is observed against an unlocked twin of the same fleet fed full forward and the same +// Turn through the same ticks: the two must answer alike, with no constant of the old mixer in the assertion. public sealed class ThrottleLockTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-throttlelock-" + Guid.NewGuid().ToString("N")); @@ -22,7 +24,8 @@ public sealed class ThrottleLockTests : IDisposable { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, - QualityPriceModifier = new ExponentialLerp() + QualityPriceModifier = new ExponentialLerp(), + TorqueMultiplier = 1f }; private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; @@ -99,59 +102,73 @@ public sealed class ThrottleLockTests : IDisposable }; } - // Spec ALockedThrottleIgnoresIntent. Mutation: read MovementDirection in place of move at any of the six - // sites in Ship.Update and the matching assertion goes red. + private static float[] Axes(Fixture f) => f.Ship.GetBehaviors().Select(t => t.Axis).ToArray(); + + private static float Apart(float[] a, float[] b) => a.Zip(b, (x, y) => Math.Abs(x - y)).Max(); + + // Drives a (possibly locked) ship at `intent` and its unlocked twin at full forward, both at the same Turn and dt, and + // requires every thruster to answer alike. + private static void Tick(Fixture ship, Fixture twin, float2 intent, float turn, float dt) + { + ship.Ship.MovementDirection = intent; + twin.Ship.MovementDirection = float2(0, 1); + ship.Ship.Turn = twin.Ship.Turn = turn; + ship.Ship.Update(dt); + twin.Ship.Update(dt); + Assert.True(Apart(Axes(ship), Axes(twin)) < 1e-6f, "the ship's throttles differ from its full-forward twin's"); + Assert.True(twin.Forward.Axis > .5f, "fixture: the twin's forward drive is firing"); + } + + // Spec ALockedThrottleIgnoresIntent. Mutation: read MovementDirection in place of move in Ship.Update and a locked + // ship stops answering as its full-forward twin does. [Fact] public void ALockedThrottleIgnoresIntent() { var f = Build(100f); + var twin = Build(100f); using var _ = f.Cache; + using var __ = twin.Cache; f.ActivateLock(); Assert.True(f.Ship.ThrottleLocked); + Assert.False(twin.Ship.ThrottleLocked); foreach (var intent in new[] { float2(0, -1), float2(1, 0), float2(-1, 0) }) - { - f.Ship.MovementDirection = intent; - f.Ship.Turn = 0; - f.Ship.Update(0.01f); - Assert.Equal(1f, f.Forward.Axis); - Assert.Equal(0f, f.Reverse.Axis); - Assert.All(f.Right, t => Assert.Equal(0f, t.Axis)); - Assert.All(f.Left, t => Assert.Equal(0f, t.Axis)); - } + Tick(f, twin, intent, 0, 0.01f); - // Turning stays with the pilot: the clockwise-torque thruster follows Turn while the lock holds. - f.Ship.MovementDirection = float2(0, -1); - f.Ship.Turn = 1; - f.Ship.Update(0.01f); - Assert.Equal(1f, f.Clockwise.Axis); - f.Ship.Turn = 0; - f.Ship.Update(0.01f); - Assert.Equal(0f, f.Clockwise.Axis); + // Turning stays with the pilot: a Turn moves the throttles while the lock holds, and the lock follows it. + Tick(f, twin, float2(0, -1), 1, 0.01f); + var turning = Axes(twin); + Tick(f, twin, float2(0, -1), 0, 0.01f); + Assert.True(Apart(turning, Axes(twin)) > .01f, "a Turn changes the throttles"); } - // Spec TheLockEndsWithItsEffect. Mutation: make ThrottleLocked a latch (set on activation, never cleared) - // and the post-expiry reverse axis stays 0. + // Spec TheLockEndsWithItsEffect. Mutation: make ThrottleLocked a latch (set on activation, never cleared) and the + // ship keeps answering as its full-forward twin after expiry. [Fact] public void TheLockEndsWithItsEffect() { var f = Build(1f); + var twin = Build(1f); using var _ = f.Cache; + using var __ = twin.Cache; f.ActivateLock(); - f.Ship.MovementDirection = float2(0, -1); - f.Ship.Update(0.5f); - Assert.Equal(0f, f.Reverse.Axis); + Tick(f, twin, float2(0, -1), 0, 0.5f); + Assert.True(f.Reverse.Axis < 1e-6f); - f.Ship.Update(2f); // runs past the Duration; the effect leaves at the end of this tick + Tick(f, twin, float2(0, -1), 0, 2f); // runs past the Duration; the effect leaves at the end of this tick Assert.False(f.Ship.ThrottleLocked); + f.Ship.MovementDirection = float2(0, -1); + twin.Ship.MovementDirection = float2(0, 1); f.Ship.Update(0.01f); - Assert.Equal(1f, f.Reverse.Axis); - Assert.Equal(0f, f.Forward.Axis); + twin.Ship.Update(0.01f); + Assert.True(f.Reverse.Axis > .5f); + Assert.True(f.Forward.Axis < 1e-6f); + Assert.True(Apart(Axes(f), Axes(twin)) > .5f, "the unlocked ship follows its stick, not the twin's full forward"); } - // Spec AnUnlockedShipFollowsIntent. Mutation: replace the conditional by an unconditional float2(0, 1) and - // the reverse and strafe assertions go red. + // Spec AnUnlockedShipFollowsIntent. Mutation: replace the conditional by an unconditional float2(0, 1) and the four + // intents give the same throttles. [Fact] public void AnUnlockedShipFollowsIntent() { @@ -159,26 +176,33 @@ public sealed class ThrottleLockTests : IDisposable using var _ = f.Cache; Assert.False(f.Ship.ThrottleLocked); - f.Ship.MovementDirection = float2(0, -1); - f.Ship.Update(0.01f); - Assert.Equal(1f, f.Reverse.Axis); - Assert.Equal(0f, f.Forward.Axis); + float[] Fly(float2 intent) + { + f.Ship.MovementDirection = intent; + f.Ship.Update(0.01f); + return Axes(f); + } - f.Ship.MovementDirection = float2(1, 0); - f.Ship.Update(0.01f); - Assert.All(f.Right, t => Assert.Equal(1f, t.Axis)); - Assert.All(f.Left, t => Assert.Equal(0f, t.Axis)); - Assert.Equal(0f, f.Forward.Axis); + var back = Fly(float2(0, -1)); + Assert.True(f.Reverse.Axis > .5f); + Assert.True(f.Forward.Axis < 1e-6f); - f.Ship.MovementDirection = float2(-1, 0); - f.Ship.Update(0.01f); - Assert.All(f.Left, t => Assert.Equal(1f, t.Axis)); - Assert.All(f.Right, t => Assert.Equal(0f, t.Axis)); + var right = Fly(float2(1, 0)); + Assert.True(f.Right.Max(t => t.Axis) > .5f); + Assert.All(f.Left, t => Assert.True(t.Axis < 1e-6f)); - f.Ship.MovementDirection = float2(0, 1); - f.Ship.Update(0.01f); - Assert.Equal(1f, f.Forward.Axis); - Assert.Equal(0f, f.Reverse.Axis); + var left = Fly(float2(-1, 0)); + Assert.True(f.Left.Max(t => t.Axis) > .5f); + Assert.All(f.Right, t => Assert.True(t.Axis < 1e-6f)); + + var forward = Fly(float2(0, 1)); + Assert.True(f.Forward.Axis > .5f); + Assert.True(f.Reverse.Axis < 1e-6f); + + var all = new[] { back, right, left, forward }; + for (var a = 0; a < all.Length; a++) + for (var b = a + 1; b < all.Length; b++) + Assert.True(Apart(all[a], all[b]) > .1f, "two different intents gave the same throttles"); } // Finding zero-stick-unpinned. Mutation: float2(0, length(MovementDirection)) as the locked intent. The lock @@ -187,51 +211,42 @@ public sealed class ThrottleLockTests : IDisposable public void ALockedShipGetsFullForwardWhateverTheStickLength() { var f = Build(100f); + var twin = Build(100f); using var _ = f.Cache; + using var __ = twin.Cache; f.ActivateLock(); foreach (var intent in new[] { float2(0, 0), float2(.3f, .2f), float2(-.5f, -.5f) }) - { - f.Ship.MovementDirection = intent; - f.Ship.Turn = 0; - f.Ship.Update(0.01f); - Assert.Equal(1f, f.Forward.Axis); - Assert.Equal(0f, f.Reverse.Axis); - Assert.All(f.Right, t => Assert.Equal(0f, t.Axis)); - Assert.All(f.Left, t => Assert.Equal(0f, t.Axis)); - } + Tick(f, twin, intent, 0, 0.01f); } - // Finding ccw-turn-unpinned. Mutation: drop the counter-clockwise Turn. Turn in either sign reaches only the - // thrusters with torque of that sign, at full, while the lock holds forward. + // Finding ccw-turn-unpinned. Mutation: drop the counter-clockwise Turn. Turn in either sign gives the locked ship + // its full-forward twin's throttles, and the twin's solved net turns the way it was asked. [Fact] public void ALockedShipTurnsBothWays() { var f = Build(100f); + var twin = Build(100f); using var _ = f.Cache; + using var __ = twin.Cache; f.ActivateLock(); - var all = f.Ship.GetBehaviors().ToArray(); - var cw = all.Where(t => t.Torque > 0 && t.Item.EquippableItem.Rotation == ItemRotation.None).ToArray(); - var ccw = all.Where(t => t.Torque < 0 && t.Item.EquippableItem.Rotation == ItemRotation.None).ToArray(); - Assert.NotEmpty(cw); - Assert.NotEmpty(ccw); - f.Ship.MovementDirection = float2(1, -1); + var thrusters = twin.Ship.GetBehaviors().ToArray(); foreach (var turn in new[] { 1f, -1f }) { - f.Ship.Turn = turn; - f.Ship.Update(0.01f); - Assert.All(turn > 0 ? cw : ccw, t => Assert.Equal(1f, t.Axis)); - Assert.All(turn > 0 ? ccw : cw, t => Assert.Equal(0f, t.Axis)); - Assert.Equal(1f, f.Forward.Axis); + Tick(f, twin, float2(1, -1), turn, 0.01f); + var yaw = thrusters.Sum(t => t.Axis * t.Column(t.NominalThrust).z); + Assert.True(yaw * turn > 1e-4f, "the solved net turns the way it was asked"); } } // Finding non-lock-consumable-unpinned. Mutation: any active consumable locks. A consumable without - // ThrottleLockData leaves the pilot's intent in force. + // ThrottleLockData leaves the pilot's intent in force: the ship answers as an unlocked ship at the same stick. [Fact] public void AConsumableWithoutTheLockDoesNotLock() { var f = Build(100f); + var twin = Build(100f); using var _ = f.Cache; + using var __ = twin.Cache; var plain = new ConsumableItemData { Name = "Plain", Duration = 100f }; f.Cache.Upsert(plain); f.Ship.ActivateConsumable(new ConsumableItem @@ -240,15 +255,17 @@ public sealed class ThrottleLockTests : IDisposable Lot = f.Items.Lots.Add(new Lot { Design = f.Cache.RefOf(plain), Origin = new Attributed(), Quality = .5f, Roles = new List() }) }); Assert.False(f.Ship.ThrottleLocked); - f.Ship.MovementDirection = float2(0, -1); + f.Ship.MovementDirection = twin.Ship.MovementDirection = float2(0, -1); f.Ship.Update(0.01f); - Assert.Equal(1f, f.Reverse.Axis); - Assert.Equal(0f, f.Forward.Axis); + twin.Ship.Update(0.01f); + Assert.True(Apart(Axes(f), Axes(twin)) < 1e-6f); + Assert.True(f.Reverse.Axis > .5f); + Assert.True(f.Forward.Axis < 1e-6f); } // Finding lock-end-boundary-unpinned, finding lock-end-at-90pct-unpinned. Mutations: the lock ends at half // Duration, at 0.8 or 0.9 of Duration, or one tick late. Duration 1 stepped in 0.25 s ticks (exact in - // floats) locks the axes on exactly four ticks (reverse stays 0), the effect is gone when the fourth ends, + // floats) answers as the full-forward twin on exactly four ticks, the effect is gone when the fourth ends, // and the fifth tick follows the stick. The count scales with Duration: 2 s locks eight ticks. [Theory] [InlineData(1f, 4)] @@ -256,19 +273,22 @@ public sealed class ThrottleLockTests : IDisposable public void TheLockEndsAtTheExactTickOfItsDuration(float duration, int lockedTicks) { var f = Build(duration); + var twin = Build(duration); using var _ = f.Cache; + using var __ = twin.Cache; f.ActivateLock(); - f.Ship.MovementDirection = float2(0, -1); for (var i = 0; i < lockedTicks; i++) { Assert.True(f.Ship.ThrottleLocked); - f.Ship.Update(.25f); - Assert.Equal(0f, f.Reverse.Axis); - Assert.Equal(1f, f.Forward.Axis); + Tick(f, twin, float2(0, -1), 0, .25f); + Assert.True(f.Reverse.Axis < 1e-6f); } Assert.False(f.Ship.ThrottleLocked); + f.Ship.MovementDirection = float2(0, -1); + twin.Ship.MovementDirection = float2(0, 1); f.Ship.Update(.25f); - Assert.Equal(1f, f.Reverse.Axis); - Assert.Equal(0f, f.Forward.Axis); + twin.Ship.Update(.25f); + Assert.True(f.Reverse.Axis > .5f); + Assert.True(f.Forward.Axis < 1e-6f); } } diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs new file mode 100644 index 000000000..ab0b1e86c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -0,0 +1,212 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Flight control allocates thrust (ruling flight-control-allocates-thrust): Ship.Update hands the intent and every +// thruster's live column to one ThrustAllocator, and a ship flies on what that answers. These tests fly the Duel's +// Longinus (two mismatched Large Drives forward, a Talaria on each flank) through the real Ship.Update; a test that reads +// velocity steps the arena, because the zone places the orbits that Ship.Update alone leaves stacked at the spawn. +public sealed partial class RunStartTests +{ + private const float AllocDt = 1f / 60f; + + private (Zone arena, Ship player) AllocDuel() + { + var launched = Launch(new Duel()); + Assert.Empty(launched.failures); + return (launched.arena, launched.staged.Player); + } + + private static Thruster[] AllocDrives(Ship ship) => + ship.GetBehaviors().Where(t => t.Item.EquippableItem.Rotation == ItemRotation.Reversed).OrderBy(t => t.NominalThrust).ToArray(); + + // The signed heading change in degrees, positive clockwise, of `ticks` of the ship's own Update. + private static float AllocFly(Ship ship, int ticks) + { + var before = ship.Direction; + for (var tick = 0; tick < ticks; tick++) ship.Update(AllocDt); + return degrees(SignedTurn(before, ship.Direction)); + } + + [Fact] + public void MismatchedDrivesHoldHeading() + { + var (_, player) = AllocDuel(); + var drives = AllocDrives(player); + Assert.True(drives.Length >= 2 && drives[^1].NominalThrust > drives[0].NominalThrust * 1.05f, "fixture: the drives are mismatched"); + player.MovementDirection = float2(0, 1); + player.Turn = 0; + Assert.True(abs(AllocFly(player, 120)) < .1f); + Assert.All(drives, d => Assert.True(d.Axis > .5f, "fixture: the drives are pushing")); + } + + [Theory] + [InlineData("strongest disabled")] + [InlineData("weakest disabled")] + [InlineData("one destroyed")] + public void ALostDriveStillHoldsHeading(string loss) + { + var (_, player) = AllocDuel(); + var drives = AllocDrives(player); + var lost = loss == "weakest disabled" ? drives[0] : drives[^1]; + var kept = loss == "weakest disabled" ? drives[^1] : drives[0]; + player.MovementDirection = float2(0, 1); + player.Turn = 0; + for (var tick = 0; tick < 10; tick++) player.Update(AllocDt); + if (loss == "one destroyed") + { + var destroyed = 0; + using var sub = player.ItemDestroyed.Subscribe(_ => destroyed++); + player.ItemAbsorb(lost.Item, new[] { 1e6f }); + Assert.Equal(1, destroyed); // the death event fired + } + else lost.Item.Enabled.Value = false; + player.Update(AllocDt); + Assert.False(lost.Item.Active.Value); + + Assert.True(abs(AllocFly(player, 120)) < .1f); + Assert.True(kept.Axis > .5f, "the live drive pushes"); + Assert.Equal(0f, lost.Axis); + } + + [Fact] + public void ADegradedDriveIsBalancedLive() + { + var (_, player) = AllocDuel(); + var drives = AllocDrives(player); + player.MovementDirection = float2(0, 1); + player.Turn = 0; + Assert.True(abs(AllocFly(player, 30)) < .1f); + + var weakened = drives[^1]; + var before = weakened.Thrust; + weakened.Item.EquippableItem.Durability *= .5f; + weakened.Item.UpdatePerformance(); + Assert.True(weakened.Thrust < before * .95f, "fixture: halving durability weakens the drive"); + Assert.True(abs(AllocFly(player, 60)) < .1f); + } + + [Fact] + public void ATinyImbalanceStillHolds() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var ship = RestoredHullsTests.BuildThrustedShip(cache, "Longinus"); + var drives = AllocDrives(ship); + Assert.True(drives.Length >= 2); + var other = drives[0]; + var detuned = drives[1]; + + // Detune one drive until its live thrust is .4% under the other's, by bisecting its durability. + var full = detuned.Item.Data.Durability; + float lo = 0, hi = 1; + for (var step = 0; step < 50; step++) + { + var mid = (lo + hi) / 2; + detuned.Item.EquippableItem.Durability = full * mid; + detuned.Item.UpdatePerformance(); + if (detuned.Thrust < other.Thrust * .996f) lo = mid; else hi = mid; + } + var ratio = detuned.Thrust / other.Thrust; + Assert.InRange(ratio, .995f, .998f); + + ship.MovementDirection = float2(0, 1); + ship.Turn = 0; + Assert.True(abs(AllocFly(ship, 600)) < .05f); + } + + [Fact] + public void ARepairedDriveRejoins() + { + var (_, player) = AllocDuel(); + var drive = AllocDrives(player)[^1]; + player.MovementDirection = float2(0, 1); + player.Turn = 0; + drive.Item.Enabled.Value = false; + for (var tick = 0; tick < 30; tick++) player.Update(AllocDt); + Assert.Equal(0f, drive.Axis); + drive.Item.Enabled.Value = true; + player.Update(AllocDt); + Assert.True(drive.Axis > .5f); + } + + [Fact] + public void ADeadThrusterNeverGetsThrottle() + { + var (_, player) = AllocDuel(); + var thrusters = player.GetBehaviors().ToArray(); + Assert.True(thrusters.Length >= 4); + var asks = new (float2 move, float turn)[] + { + (float2(0, 1), 0), (float2(0, -1), 0), (float2(1, 0), 0), (float2(-1, 0), 0), (float2(.7f, .7f), 0), + (float2(0, 0), 1), (float2(0, 0), -1) + }; + foreach (var dead in thrusters) + { + dead.Item.Enabled.Value = false; + foreach (var (move, turn) in asks) + { + player.MovementDirection = move; + player.Turn = turn; + for (var tick = 0; tick < 30; tick++) + { + player.Update(AllocDt); + Assert.Equal(0f, dead.Axis); + } + } + dead.Item.Enabled.Value = true; + } + } + + [Fact] + public void AFullTurnSpendsTheHullsWholeTurn() + { + var (_, player) = AllocDuel(); + var thrusters = player.GetBehaviors().ToArray(); + player.MovementDirection = float2(0, 1); + player.Turn = 1; + var sum = 0f; + var extremeSum = 0f; + var count = 0; + for (var tick = 0; tick < 60; tick++) + { + // The clockwise box extreme this tick: every thruster that turns clockwise, at full, from its own column. + var extreme = thrusters.Sum(t => max(t.Column(t.NominalThrust).z, 0f)); + player.Update(AllocDt); + if (tick < 10) continue; + sum += player.TurnRate; + extremeSum += extreme; + count++; + } + Assert.True(extremeSum > 0f); + Assert.True(sum >= .98f * extremeSum, $"the turn spent {sum / extremeSum} of the hull's whole"); + } + + [Fact] + public void TheDuelFliesStraightWhenTheArenaIsStepped() + { + var (arena, player) = AllocDuel(); + player.MovementDirection = float2(0, 1); + player.Turn = 0; + var start = player.Direction; + var previous = 0f; + for (var tick = 0; tick < 240; tick++) + { + arena.Update(AllocDt); + var along = dot(player.Velocity, normalize(player.Direction)); + var oneTickOfDrag = previous - decay(previous, player.HullData.Drag, AllocDt); + Assert.True(along >= previous - oneTickOfDrag - 1e-3f, $"tick {tick}: along-nose speed fell from {previous} to {along}"); + previous = along; + } + Assert.True(previous > 10f, "fixture: the ship got going"); + Assert.True(abs(degrees(SignedTurn(start, player.Direction))) < .1f); + } +} diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 95ca77bda..b74cc05a2 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -420,4 +420,31 @@ public class ThrustAllocatorTests Assert.InRange(Solve(duel, 0, 0, .5f)[0], 1.2e-4f, LeakBound); Assert.InRange(Solve(duel, 0, 0, -.5f)[1], 1.05e-4f, LeakBound); } + + [Fact] + public void AMicroIntentIsServed() + { + // Zero is a fact about the intent alone (follow-up allocator-residual-pins): an intent of 1e-6 or 1e-10 of the + // half-axis is not zero, so something fires and the net goes the way it was asked. + void Served(float3[] hull, float x, float y, float turn, string what) + { + var throttle = Solve(hull, x, y, turn); + var net = Net(hull, throttle); + var asked = x != 0 ? x : y != 0 ? y : turn; + var got = x != 0 ? net.x : y != 0 ? net.y : net.z; + Assert.True(throttle.Max() > 0f, $"{what}: nothing fired"); + Assert.True(got * asked > 0f, $"{what}: the net does not go the way it was asked"); + } + + foreach (var micro in new[] { 1e-6f, 1e-10f }) + { + Served(Duel(), 0, 0, micro, "duel turn +"); + Served(Duel(), 0, 0, -micro, "duel turn -"); + Served(Duel(), 0, micro, 0, "duel forward"); + Served(Symmetric(), micro, 0, 0, "symmetric starboard"); + Served(Symmetric(), -micro, 0, 0, "symmetric port"); + Served(Symmetric(), 0, micro, 0, "symmetric forward"); + Served(Symmetric(), 0, -micro, 0, "symmetric reverse"); + } + } }