From 618e53336f3d8c884a8e24207a0198fcd9820e0f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 13:39:23 +0200 Subject: [PATCH 01/19] Delete TurnTime and IAnalogBehavior TurnTime has no caller; IAnalogBehavior's only implementer is Thruster. Pure deletes, no behaviour change. Not compiled in Unity (the operator's); the Shared tests build at the end. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Behaviors/Behaviors.cs | 5 ----- Assets/Scripts/ServerShared/Behaviors/Thruster.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 2 -- Assets/Scripts/ServerShared/Ship.cs | 7 ------- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 244af7d89..1a12d4282 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -93,11 +93,6 @@ public interface IActivatedBehavior void Deactivate(); } -public interface IAnalogBehavior -{ - float Axis { get; set; } -} - public interface IEventBehavior { void ResetEvents(); diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 007bf66bc..35894febb 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -37,7 +37,7 @@ public class ThrusterData : BehaviorData } } -public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer +public class Thruster : Behavior, IPowerConsumer { // Live: heat, quality, durability and power move this stat after construction, so it is read, never cached. public float Thrust => Evaluate(_data.Thrust); diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 59bc38cc9..90fb6e0b5 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2356,6 +2356,4 @@ public class BehaviorGroup return null; } - - //public IAnalogBehavior Axis; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 993d92940..6848c6073 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -59,13 +59,6 @@ public class Ship : Entity public float RightStrafeTotalTorque { get; private set; } private List RightStrafeTorqueThrusters = new List(); - public float TurnTime(float2 direction) - { - var angleDiff = Direction.Angle(normalize(direction)); - var clockwise = dot(direction, Direction.Rotate(ItemRotation.Clockwise)) > 0; - return angleDiff / ((clockwise ? ClockwiseTorque : CounterClockwiseTorque) / Mass); - } - public event Action OnExitedWormhole; public event Action OnEnteredWormhole; From c349de9c315bcf809383944aa401fdeb7972f7c9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 13:40:06 +0200 Subject: [PATCH 02/19] Delete the thrust mixer, buckets, aggregates, trim and pruning; wire ThrustAllocator into Ship.Update Ship.Update hands the intent (the throttle lock's one line) and every thruster's live Column(NominalThrust) to one ThrustAllocator and writes each Axis from its throttle. Thruster gains NominalThrust and Column; the .01 throttle gates become > 0. Does not build the test project: EvasionTermTests still read the deleted aggregates (next commit rewrites them). Not compiled in Unity. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/Thruster.cs | 16 +- Assets/Scripts/ServerShared/Ship.cs | 167 ++---------------- 2 files changed, 26 insertions(+), 157 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 35894febb..ff4ce05cf 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -41,8 +41,20 @@ public class Thruster : Behavior, IPowerConsumer { // Live: heat, quality, durability and power move this stat after construction, so it is read, never cached. public float Thrust => Evaluate(_data.Thrust); + // Thrust at a full grant, the same stat read without the power-supply term: what a column promises. Live, like Thrust. + public float NominalThrust => EvaluateNominalPower(_data.Thrust); public float Torque { get; } + // This thruster's effect per unit throttle at the given thrust, in the body frame: xy is the push (x starboard, y + // forward), z the clockwise yaw rate in rad/s. Zero when the item is absent or offline. The one geometry owner for + // the allocator and the envelope. + public float3 Column(float thrust) + { + if (Item == null || !Item.Active.Value) return default; + var push = -float2(0, 1).Rotate(Item.EquippableItem.Rotation) * thrust / Entity.Mass; + return float3(push.x, push.y, Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass); + } + public float Axis { get => _input; @@ -84,7 +96,7 @@ public class Thruster : Behavior, IPowerConsumer // registered request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every // other IPowerConsumer in this cut, though EnergyUsage carries no PowerSupply term in Thruster's own shipped // catalog today; Thrust (the field Cut 7 curves) is a separate stat Execute reads with the real Evaluate. - public float PowerRequest(float dt) => _input > .01f ? _input * EvaluateNominalPower(_data.EnergyUsage) : 0f; + public float PowerRequest(float dt) => _input > 0f ? _input * EvaluateNominalPower(_data.EnergyUsage) : 0f; // Cut 5 (docs/stats-and-power-cut.md ยง1.3, PowerTiers.cs): Medium -- mobility. Losing thrust for a tick // under brownout is an inconvenience, not the cascading failure a starved radiator or shield causes. @@ -121,7 +133,7 @@ public class Thruster : Behavior, IPowerConsumer // Thrust stat carries a PowerSupply term, a partial grant already comes back reduced -- this gate no // longer demands a full grant, only that the thruster is being asked to do anything (_input) and that it // has not been cut to true zero supply (the epsilon PowerBus itself already treats as "nothing granted"). - if(_input > .01f && Item.PowerSupply > 1e-4f) + if(_input > 0f && Item.PowerSupply > 1e-4f) { var thrust = Thrust; Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 6848c6073..7daae5c9c 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -30,14 +30,11 @@ public class Ship : Entity public float Turn; public bool IsPlayerShip; - private HashSet _thrusterItems; private Thruster[] _allThrusters; - private HashSet _forwardThrusters; - private HashSet _reverseThrusters; - private HashSet _rightThrusters; - private HashSet _leftThrusters; - private HashSet _clockwiseThrusters; - private HashSet _counterClockwiseThrusters; + // One column per thruster (its effect per unit throttle, in the body frame) and the throttles the allocator answers with. + private float3[] _columns; + private float[] _throttles; + private readonly ThrustAllocator _allocator = new ThrustAllocator(); private bool _exitingWormhole = false; private bool _enteringWormhole = false; @@ -48,16 +45,6 @@ public class Ship : Entity private float2 _wormholeExitVelocity; public bool WormholeAnimationInProgress => _enteringWormhole || _exitingWormhole; - public float ForwardThrust { get; private set; } - public float ReverseThrust { get; private set; } - public float LeftStrafeThrust { get; private set; } - public float RightStrafeThrust { get; private set; } - public float ClockwiseTorque { get; private set; } - public float CounterClockwiseTorque { get; private set; } - public float LeftStrafeTotalTorque { get; private set; } - private List LeftStrafeTorqueThrusters = new List(); - public float RightStrafeTotalTorque { get; private set; } - private List RightStrafeTorqueThrusters = new List(); public event Action OnExitedWormhole; public event Action OnEnteredWormhole; @@ -88,49 +75,18 @@ public class Ship : Entity base.Activate(); _allThrusters = GetBehaviors().ToArray(); - _thrusterItems = new HashSet(_allThrusters.Select(x=>x.Item)); - - _forwardThrusters = new HashSet(_allThrusters - .Where(x => x.Item.EquippableItem.Rotation == ItemRotation.Reversed)); - - _reverseThrusters = new HashSet(_allThrusters - .Where(x => x.Item.EquippableItem.Rotation == ItemRotation.None)); - - _rightThrusters = new HashSet(_allThrusters - .Where(x => x.Item.EquippableItem.Rotation == ItemRotation.CounterClockwise)); - - _leftThrusters = new HashSet(_allThrusters - .Where(x => x.Item.EquippableItem.Rotation == ItemRotation.Clockwise)); - - _counterClockwiseThrusters = new HashSet(_allThrusters - .Where(x => x.Torque < -ItemManager.GameplaySettings.TorqueFloor)); - - _clockwiseThrusters = new HashSet(_allThrusters - .Where(x => x.Torque > ItemManager.GameplaySettings.TorqueFloor)); + _columns = new float3[_allThrusters.Length]; + _throttles = new float[_allThrusters.Length]; } public Ship(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings) : base(itemManager, zone, hull, settings) { - ItemDestroyed.Where(item=>_thrusterItems.Contains(item)).Subscribe(RemoveThruster); - } - - private void RemoveThruster(EquippedItem item) - { - _thrusterItems.Remove(item); - var thruster = item.GetBehavior(); - if (_forwardThrusters.Contains(thruster)) _forwardThrusters.Remove(thruster); - if (_reverseThrusters.Contains(thruster)) _reverseThrusters.Remove(thruster); - if (_rightThrusters.Contains(thruster)) _rightThrusters.Remove(thruster); - if (_leftThrusters.Contains(thruster)) _leftThrusters.Remove(thruster); - if (_clockwiseThrusters.Contains(thruster)) _clockwiseThrusters.Remove(thruster); - if (_counterClockwiseThrusters.Contains(thruster)) _counterClockwiseThrusters.Remove(thruster); } #region ThrustCalculation // What this ship can do at this instant (see ManoeuvreEnvelope): the sum of what each live propulsor reports with - // its own Execute arithmetic; thrusters are its only propulsors. Derived each update, never saved. The aggregates above are not an envelope (they - // mix units and cancel a lone off-axis strafer), so nothing in it reads them. + // its own Execute arithmetic; thrusters are its only propulsors. Derived each update, never saved. public ManoeuvreEnvelope Envelope { get; private set; } private void RecalculateEnvelope() @@ -140,118 +96,19 @@ public class Ship : Entity Envelope = envelope; } - private void RecalculateThrust() - { - RecalculateEnvelope(); - RecalculateForwardThrust(); - RecalculateReverseThrust(); - RecalculateLeftStrafeThrust(); - RecalculateRightStrafeThrust(); - RecalculateClockwiseTorque(); - RecalculateCounterClockwiseTorque(); - } - - private void RecalculateForwardThrust() - { - ForwardThrust = 0; - foreach (var thruster in _forwardThrusters) - if (thruster.Item.Active.Value) - ForwardThrust += thruster.Thrust; - } - - private void RecalculateReverseThrust() - { - ReverseThrust = 0; - foreach (var thruster in _reverseThrusters) - if (thruster.Item.Active.Value) - ReverseThrust += thruster.Thrust; - } - - private void RecalculateLeftStrafeThrust() - { - LeftStrafeThrust = 0; - LeftStrafeTotalTorque = 0; - foreach (var thruster in _leftThrusters) - { - if(thruster.Item.Active.Value) - { - LeftStrafeThrust += thruster.Thrust; - LeftStrafeTotalTorque += thruster.Torque * thruster.Thrust; - } - } - LeftStrafeTorqueThrusters.Clear(); - foreach(var thruster in _leftThrusters) - if (abs(sign(thruster.Torque) - sign(LeftStrafeTotalTorque)) < .01f) - LeftStrafeTorqueThrusters.Add(thruster); - } - - private void RecalculateRightStrafeThrust() - { - RightStrafeThrust = 0; - RightStrafeTotalTorque = 0; - foreach (var thruster in _rightThrusters) - { - if(thruster.Item.Active.Value) - { - RightStrafeThrust += thruster.Thrust; - RightStrafeTotalTorque += thruster.Torque * thruster.Thrust; - } - } - RightStrafeTorqueThrusters.Clear(); - foreach(var thruster in _rightThrusters) - if (abs(sign(thruster.Torque) - sign(RightStrafeTotalTorque)) < .01f) - RightStrafeTorqueThrusters.Add(thruster); - } - - private void RecalculateClockwiseTorque() - { - ClockwiseTorque = 0; - foreach (var thruster in _clockwiseThrusters) - if (thruster.Item.Active.Value) - ClockwiseTorque += thruster.Torque; - } - - private void RecalculateCounterClockwiseTorque() - { - CounterClockwiseTorque = 0; - foreach (var thruster in _counterClockwiseThrusters) - if (thruster.Item.Active.Value) - CounterClockwiseTorque -= thruster.Torque; - } - #endregion public override void Update(float delta) { if (_active && !_exitingWormhole && !_enteringWormhole) { - RecalculateThrust(); + RecalculateEnvelope(); // The lock replaces intent here, once; player and agent intent both pass through this one read. var move = ThrottleLocked ? float2(0, 1) : MovementDirection; - foreach (var thruster in _allThrusters) thruster.Axis = 0; - var rightThrusterTorqueCompensation = abs(RightStrafeTotalTorque) / RightStrafeTorqueThrusters.Count; - foreach (var thruster in _rightThrusters) - { - var thrust = 0f; - thrust += move.x; - if (RightStrafeTorqueThrusters.Contains(thruster)) - thrust -= move.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust)); - thruster.Axis = thrust; - } - var leftThrusterTorqueCompensation = abs(LeftStrafeTotalTorque) / LeftStrafeTorqueThrusters.Count; - foreach (var thruster in _leftThrusters) - { - var thrust = 0f; - thrust += -move.x; - if (LeftStrafeTorqueThrusters.Contains(thruster)) - thrust += move.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust)); - thruster.Axis = thrust; - } - foreach (var thruster in _forwardThrusters) thruster.Axis += move.y; - foreach (var thruster in _reverseThrusters) thruster.Axis += -move.y; - - foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn; - foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn; + for (var i = 0; i < _allThrusters.Length; i++) + _columns[i] = _allThrusters[i].Column(_allThrusters[i].NominalThrust); + _allocator.Allocate(_columns, move, Turn, _throttles); + for (var i = 0; i < _allThrusters.Length; i++) _allThrusters[i].Axis = _throttles[i]; } var velocityMagnitude = length(Velocity); From df61900db80158d4e733b65c8cb3274b7b8f745c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 13:44:22 +0200 Subject: [PATCH 03/19] 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"); + } + } } From ffda2a8a7662b3a61fb300047e29c3a451180dc5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 13:47:58 +0200 Subject: [PATCH 04/19] Tests: add the missing usings (UniRx, System.Linq) Fixes the compile errors of the previous commit; that commit does not build. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 1 + tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index ab0b1e86c..0e96ed191 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; using CultMath; +using UniRx; using Xunit; using static CultMath.math; using float2 = CultMath.float2; diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index b74cc05a2..2e293ddb2 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CultMath; using Xunit; From ab450e72d30e080975784e59846d72ddd4b3e867 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:24:05 +0200 Subject: [PATCH 05/19] Tests follow the allocator: warm-up, free thruster power, heat-detuned drives The allocator reads Item.Active every tick, so fixtures that fire a thruster on their first update warm its performance first; flight fixtures take the thrusters' energy draw to zero so a brownout does not scale the turn; the Duel drives are mismatched after they heat apart, and degraded by heat (the Large Drive has no durability term); the consumer sits at (2,1), the Skiff's interior centre column. ReachIsWhatTheShipCanDo's Longinus ceiling is 1.3 (measured 1.195 nose-on, 1.249 broadside). Previous test commits do not build or pass; this one is unverified until the full run. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 9 ++++++--- tests/Aetheria.Shared.Tests/ConditionRatioTests.cs | 4 ++-- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 6 +++++- tests/Aetheria.Shared.Tests/RestoredHullsTests.cs | 10 ++++++++++ tests/Aetheria.Shared.Tests/SteeringTests.cs | 1 + tests/Aetheria.Shared.Tests/ThrottleLockTests.cs | 2 ++ tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 9 ++++++--- 7 files changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index b7a13fdc2..432b42d51 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -111,8 +111,7 @@ public sealed class BrownoutTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); // 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"); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 1))); zone.Entities.Add(ship); // Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the @@ -132,6 +131,8 @@ public sealed class BrownoutTests : IDisposable Thrust = Curved(100, exponent), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) }); var ship = BuildShip(cache, reactorCharge); + Assert.True(ship.GetBehavior().Torque == 0f, "the lone thruster turns nothing"); + ship.Equipment.Single(e => e.Data.Name == "Consumer").UpdatePerformance(); // live from its first performance update ship.MovementDirection = float2(0, -1); // reverse-thruster axis += -MovementDirection.y == 1 ship.Update(1f); return (-ship.Velocity.y, ship.GetBehavior()); @@ -339,9 +340,11 @@ public sealed class BrownoutTests : IDisposable var energyUsage = Curved(100, exponent: 1); using var cache = OpenCatalog(new ThrusterData { - Thrust = Constant(0), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = energyUsage + Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = energyUsage }); var ship = BuildShip(cache, reactorCharge: 25); // demand 100 (nominal), generation 25 -> ratio .25 + // The allocator fires a thruster that can push; it is live from its first performance update. + ship.Equipment.Single(e => e.Data.Name == "Consumer").UpdatePerformance(); ship.MovementDirection = float2(0, -1); ship.Update(1f); var thruster = ship.GetBehavior(); diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 77b51156a..aed8a6a28 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -94,13 +94,13 @@ public sealed class ConditionRatioTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); // 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"); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 1))); zone.Entities.Add(ship); ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests ship.Activate(); consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer"); + Assert.True(ship.GetBehavior().Torque == 0f, "the lone thruster turns nothing"); // Settle at the plateau's center: two ticks so UpdatePerformance's deltaTemp term reads 0 before a test // drives its own change, matching HeatResponseTests' own settle pattern. diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index a0c823e6b..a399f6ce9 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -273,7 +273,11 @@ public sealed partial class RunStartTests // The Djinni's measured motion is further below its reach (+53% nose-on, +27% broadside at the last measurement): its // strafers are off-axis and Ship.Update cancels their torque (M24), which the envelope, summed from what each thruster // can push, does not model. Stated deviation; the bound still pins that the reach covers what the ship does. - var ceiling = name == "djinni" ? 1.6f : 1.15f; + // The Longinus's reach sits 19.5% (nose-on) and 24.9% (broadside) above what the allocator's flight measures + // (ruling allocator-divergence-accepted): the allocator holds the heading and pays for the drive torque out of + // the flank thrusters, so the envelope's box support (cut envelope-from-columns reads the allocator's columns) + // promises more than a controlled flight spends. + var ceiling = name == "djinni" ? 1.6f : 1.3f; Assert.InRange(reach.noseOn, measured.noseOn * .9f, measured.noseOn * ceiling); Assert.InRange(reach.broadside, measured.broadside * .9f, measured.broadside * ceiling); } diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index b5622b268..0dba1a567 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -268,6 +268,16 @@ public sealed class RestoredHullsTests } } + // For a fixture that pins flight, not brownout: the thrusters draw no power. A ship with every thruster firing asks + // for more than its reactor gives, and a brownout scales the thrusters' thrust unevenly (follow-up + // allocator-brownout-columns), so the turn or heading a test reads would be the brownout's. + internal static void FreeThrusterPower(CultCache cache) + { + foreach (var gear in cache.GetAll()) + foreach (var thruster in gear.Behaviors.OfType()) + thruster.EnergyUsage = new PerformanceStat { Min = 0, Max = 0 }; + } + // A restored hull with every thruster hardpoint (and its reactor) filled, activated and warmed up: the // rotation thrusters the helm tests need. The caller owns the cache. internal static Ship BuildThrustedShip(CultCache cache, string name) diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 60861ecdb..d2abbbbaa 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -290,6 +290,7 @@ public sealed class SteeringTests public void ATurnIsThatFractionOfTheHullsTurn(float turn) { using var cache = RestoredHullsTests.OpenCatalog(); + RestoredHullsTests.FreeThrusterPower(cache); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); var settings = RestoredHullsTests.Settings(); const float dt = 1f / 60f; diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index b51a9086a..f7d012f6a 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -89,6 +89,8 @@ public sealed class ThrottleLockTests : IDisposable zone.Entities.Add(ship); ship.Aim = float3(0, 0, 1); ship.Activate(); + // A thruster is live from its first performance update; the allocator reads that every tick. + foreach (var item in ship.Equipment) item.UpdatePerformance(); var thrusters = ship.GetBehaviors().ToArray(); return new Fixture diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index 0e96ed191..bba7d64c7 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -43,10 +43,11 @@ public sealed partial class RunStartTests { 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); + // The drives start alike and heat apart as they fire: by now they are mismatched and both pushing. + Assert.True(drives.Length >= 2 && drives[^1].NominalThrust > drives[0].NominalThrust * 1.05f, "fixture: the drives are mismatched"); Assert.All(drives, d => Assert.True(d.Axis > .5f, "fixture: the drives are pushing")); } @@ -88,11 +89,12 @@ public sealed partial class RunStartTests player.Turn = 0; Assert.True(abs(AllocFly(player, 30)) < .1f); + // The drive is heated a little off its plateau: its live thrust moves, and a column cached before would not. var weakened = drives[^1]; var before = weakened.Thrust; - weakened.Item.EquippableItem.Durability *= .5f; + foreach (var cell in weakened.Item.InsetShape.Coordinates) player.Temperature[cell.x, cell.y] += 5f; weakened.Item.UpdatePerformance(); - Assert.True(weakened.Thrust < before * .95f, "fixture: halving durability weakens the drive"); + Assert.True(weakened.Thrust < before * .95f && weakened.Item.Active.Value, "fixture: the heated drive pushes measurably less"); Assert.True(abs(AllocFly(player, 60)) < .1f); } @@ -100,6 +102,7 @@ public sealed partial class RunStartTests public void ATinyImbalanceStillHolds() { using var cache = RestoredHullsTests.OpenCatalog(); + RestoredHullsTests.FreeThrusterPower(cache); var ship = RestoredHullsTests.BuildThrustedShip(cache, "Longinus"); var drives = AllocDrives(ship); Assert.True(drives.Length >= 2); From 95a13952352b6b375cf64386b83c10d3c47f6bdf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:25:38 +0200 Subject: [PATCH 06/19] ThrustAllocationTests: heat the degraded drive stepwise; report the drift The commit does not claim a green run; both tests were red in the previous run. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index bba7d64c7..a3de52005 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -92,8 +92,11 @@ public sealed partial class RunStartTests // The drive is heated a little off its plateau: its live thrust moves, and a column cached before would not. var weakened = drives[^1]; var before = weakened.Thrust; - foreach (var cell in weakened.Item.InsetShape.Coordinates) player.Temperature[cell.x, cell.y] += 5f; - weakened.Item.UpdatePerformance(); + for (var step = 0; step < 20 && weakened.Thrust > before * .9f; step++) + { + foreach (var cell in weakened.Item.InsetShape.Coordinates) player.Temperature[cell.x, cell.y] += 2f; + weakened.Item.UpdatePerformance(); + } Assert.True(weakened.Thrust < before * .95f && weakened.Item.Active.Value, "fixture: the heated drive pushes measurably less"); Assert.True(abs(AllocFly(player, 60)) < .1f); } @@ -124,7 +127,8 @@ public sealed partial class RunStartTests ship.MovementDirection = float2(0, 1); ship.Turn = 0; - Assert.True(abs(AllocFly(ship, 600)) < .05f); + var drift = AllocFly(ship, 600); + Assert.True(abs(drift) < .05f, $"heading changed {drift} deg; drives {string.Join(" ", drives.Select(d => $"{d.Thrust:F0}/{d.Axis:F3}"))}"); } [Fact] From 158aeecf705dd9a00fbac1867c10ba9a8c34e083 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:26:50 +0200 Subject: [PATCH 07/19] ThrustAllocationTests: probe the tiny-imbalance drift (temporary diagnostics) Does not claim green. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index a3de52005..b9fb7ba20 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -127,8 +127,15 @@ public sealed partial class RunStartTests ship.MovementDirection = float2(0, 1); ship.Turn = 0; - var drift = AllocFly(ship, 600); - Assert.True(abs(drift) < .05f, $"heading changed {drift} deg; drives {string.Join(" ", drives.Select(d => $"{d.Thrust:F0}/{d.Axis:F3}"))}"); + var log = ""; + float drift = 0; + for (var seg = 0; seg < 10; seg++) + { + drift += AllocFly(ship, 60); + log += $" +{seg}: {drift:F3} " + string.Join(" ", ship.GetBehaviors().Select(d => $"[{d.Item.EquippableItem.Rotation} {(d.Item.Active.Value ? "on" : "OFF")} {d.Thrust:F0}/{d.Axis:F4} t{d.Item.Temperature:F0}]")); + } + Assert.True(abs(drift) < .05f, log); } [Fact] From 7378c8f1a8f2127ce47a2f3c814a9efba8f3dd5c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:27:35 +0200 Subject: [PATCH 08/19] ThrustAllocationTests: fix the diagnostic string (does not build the previous commit) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index b9fb7ba20..9ea5f0c63 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -132,8 +132,7 @@ public sealed partial class RunStartTests for (var seg = 0; seg < 10; seg++) { drift += AllocFly(ship, 60); - log += $" -{seg}: {drift:F3} " + string.Join(" ", ship.GetBehaviors().Select(d => $"[{d.Item.EquippableItem.Rotation} {(d.Item.Active.Value ? "on" : "OFF")} {d.Thrust:F0}/{d.Axis:F4} t{d.Item.Temperature:F0}]")); + log += $" || {seg}: {drift:F3} " + string.Join(" ", ship.GetBehaviors().Select(d => $"[{d.Item.EquippableItem.Rotation} {(d.Item.Active.Value ? "on" : "OFF")} {d.Thrust:F0}/{d.Axis:F4} t{d.Item.Temperature:F0}]")); } Assert.True(abs(drift) < .05f, log); } From 6666f8138522e8cedda2bb2ea408b8d9d6554a2f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 14:29:18 +0200 Subject: [PATCH 09/19] Tests: the tiny-imbalance flight runs six seconds; the stick's length and no stick are pinned The Longinus the fixture builds has no heatsinks: its drives overheat and shut down at about seven seconds (measured: drift -0.006 deg to tick 360, then the thermal shutdown), so the 600-tick flight of the spec reads the shutdown, not the allocator. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrottleLockTests.cs | 5 +++++ tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs | 10 ++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index f7d012f6a..cbb1d1a0a 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -201,6 +201,11 @@ public sealed class ThrottleLockTests : IDisposable Assert.True(f.Forward.Axis > .5f); Assert.True(f.Reverse.Axis < 1e-6f); + // The stick's length is the fraction of the hull's forward thrust asked for, and no stick asks for nothing. + Fly(float2(0, .5f)); + Assert.InRange(f.Forward.Axis, .4f, .6f); + Assert.All(Fly(float2(0, 0)), a => Assert.True(a < 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++) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index 9ea5f0c63..8e9d0c63d 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -127,14 +127,8 @@ public sealed partial class RunStartTests ship.MovementDirection = float2(0, 1); ship.Turn = 0; - var log = ""; - float drift = 0; - for (var seg = 0; seg < 10; seg++) - { - drift += AllocFly(ship, 60); - log += $" || {seg}: {drift:F3} " + string.Join(" ", ship.GetBehaviors().Select(d => $"[{d.Item.EquippableItem.Rotation} {(d.Item.Active.Value ? "on" : "OFF")} {d.Thrust:F0}/{d.Axis:F4} t{d.Item.Temperature:F0}]")); - } - Assert.True(abs(drift) < .05f, log); + // Six seconds: the ship has no heatsinks, and its drives overheat and shut down at about seven. + Assert.True(abs(AllocFly(ship, 360)) < .05f); } [Fact] From 5a2f44db94d9a3142b80d23fbd047aee0f23b8b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 15:43:52 +0200 Subject: [PATCH 10/19] ThrottleLockTests: a full stick is the hull's whole forward thrust Hand mutant move-clamped (forward intent capped at .9) survived the .5 bounds. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrottleLockTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index cbb1d1a0a..7168b54fa 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -118,7 +118,7 @@ public sealed class ThrottleLockTests : IDisposable 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"); + Assert.True(twin.Forward.Axis > .98f, "fixture: the twin's forward drive is at full"); } // Spec ALockedThrottleIgnoresIntent. Mutation: read MovementDirection in place of move in Ship.Update and a locked @@ -198,7 +198,7 @@ public sealed class ThrottleLockTests : IDisposable 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.Forward.Axis > .98f, "a full stick asks for the hull's whole forward thrust"); Assert.True(f.Reverse.Axis < 1e-6f); // The stick's length is the fraction of the hull's forward thrust asked for, and no stick asks for nothing. From caa424f424e466c992d64d3d7f6eefbadc64b2cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 16:08:04 +0200 Subject: [PATCH 11/19] BrownoutTests: a throttle under the old .01 gate still asks for its power Hand mutant powerrequest-gate-01 survived: the gate's only reach is the energy asked. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index 432b42d51..c34c6c22b 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -138,6 +138,20 @@ public sealed class BrownoutTests : IDisposable return (-ship.Velocity.y, ship.GetBehavior()); } + // A throttle the old .01 gate cut off still asks for its share of the power (the allocator balances a drive pair + // with throttles of a thousandth). + [Fact] + public void ASmallThrottleStillAsksForItsPower() + { + using var cache = OpenCatalog(new ThrusterData + { + Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) + }); + var thruster = BuildShip(cache, reactorCharge: 1000).GetBehavior(); + thruster.Axis = .005f; + Assert.Equal(.5f, thruster.PowerRequest(1f), 4); + } + [Fact] public void ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust() { From 534e402fdf9412e6a1f1483efc22912c3445ddf4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:26:49 +0200 Subject: [PATCH 12/19] Throttle lock is a forward floor on the solve; allocation moves after the performance update The lock stops being an intent rewrite in Ship.Update: ThrustAllocator.Allocate takes forwardFloor, which ignores the stick, asks full forward and holds every column with y > 0 at full (ruling overdrive-forward-floor). Ship allocates in Entity's new UpdateThrottles phase, after every item's UpdatePerformance and before the power bus and Execute, so liveness and thrust are the ones Execute acts on (finding liveness-read-before-performance). Tests follow in the next commits; the test project still compiles, new behaviour is untested here. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 5 ++++ Assets/Scripts/ServerShared/Ship.cs | 24 ++++++++++--------- .../Scripts/ServerShared/ThrustAllocator.cs | 9 ++++++- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 90fb6e0b5..87171d80e 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1373,6 +1373,10 @@ public abstract class Entity yield return b; } + // The phase between the items' performance update and the power bus: what an entity commands its items to do this + // tick, read from the same liveness and stats Execute then acts on. Ship's allocation lives here. + protected virtual void UpdateThrottles() { } + public virtual void Update(float delta) { TurnRate = 0; @@ -1402,6 +1406,7 @@ public abstract class Entity UpdateTemperature(delta); foreach (var item in _orderedEquipment) item.UpdatePerformance(); + UpdateThrottles(); if (_active) { diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 7daae5c9c..a7339c4e1 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -98,19 +98,21 @@ public class Ship : Entity #endregion + // Called by Entity.Update once the items' performance is current and before the power bus is stepped and the items + // execute: liveness, thrust and the throttles they decide are one phase, the one Execute acts on. The lock is a + // bound on the allocator's solve; intent reaches the allocator here and nowhere else. + protected override void UpdateThrottles() + { + if (_exitingWormhole || _enteringWormhole) return; + RecalculateEnvelope(); + for (var i = 0; i < _allThrusters.Length; i++) + _columns[i] = _allThrusters[i].Column(_allThrusters[i].NominalThrust); + _allocator.Allocate(_columns, MovementDirection, Turn, _throttles, ThrottleLocked); + for (var i = 0; i < _allThrusters.Length; i++) _allThrusters[i].Axis = _throttles[i]; + } + public override void Update(float delta) { - if (_active && !_exitingWormhole && !_enteringWormhole) - { - RecalculateEnvelope(); - // The lock replaces intent here, once; player and agent intent both pass through this one read. - var move = ThrottleLocked ? float2(0, 1) : MovementDirection; - for (var i = 0; i < _allThrusters.Length; i++) - _columns[i] = _allThrusters[i].Column(_allThrusters[i].NominalThrust); - _allocator.Allocate(_columns, move, Turn, _throttles); - for (var i = 0; i < _allThrusters.Length; i++) _allThrusters[i].Axis = _throttles[i]; - } - var velocityMagnitude = length(Velocity); if(velocityMagnitude > .01f) Velocity = normalize(Velocity) * decay(velocityMagnitude, HullData.Drag, delta); diff --git a/Assets/Scripts/ServerShared/ThrustAllocator.cs b/Assets/Scripts/ServerShared/ThrustAllocator.cs index 828edf993..148b98d29 100644 --- a/Assets/Scripts/ServerShared/ThrustAllocator.cs +++ b/Assets/Scripts/ServerShared/ThrustAllocator.cs @@ -31,11 +31,18 @@ public sealed class ThrustAllocator int _columns = -1; float[] _a, _b, _lo, _hi, _x, _workspace; - public void Allocate(ReadOnlySpan columns, float2 move, float turn, Span throttle) + /// + /// Solves the throttles for the intent. is the throttle lock: every thruster whose + /// column pushes forward is bounded below at full, the stick is ignored (forward is the only translation asked, so + /// nothing fires against the floored drives), and the turn is served by what remains. + /// + public void Allocate(ReadOnlySpan columns, float2 move, float turn, Span throttle, bool forwardFloor = false) { var n = columns.Length; if (n != _columns) Resize(n); for (var i = 0; i < n; i++) throttle[i] = 0; + if (forwardFloor) move = new float2(0, 1); + for (var i = 0; i < n; i++) _lo[i] = forwardFloor && columns[i].y > 0 ? 1f : 0f; // Nothing asked, nothing fired: that is a fact about the intent, decided before the solve. The solver's warm // start is left as the last solved point, which is where the next non-zero call resumes. From 34002686811c9aa6dc2ea6b0174ad1cec09b3886 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:26:51 +0200 Subject: [PATCH 13/19] Tests: forward floor under the lock, thermal death tick, nominal columns under total brownout Not yet run; this commit may not pass. Co-Authored-By below. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 22 +++++ .../ThrottleLockTests.cs | 92 +++++++++++++++++-- .../ThrustAllocationTests.cs | 35 +++++++ .../ThrustAllocatorTests.cs | 21 +++++ 4 files changed, 160 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index c34c6c22b..347ba8e89 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -152,6 +152,28 @@ public sealed class BrownoutTests : IDisposable Assert.Equal(.5f, thruster.PowerRequest(1f), 4); } + // TA6: a column promises what the thruster does at a full grant, never what the grant currently allows. Mutations: + // the allocator reads Column(Thrust) (live, power-curved) for Column(NominalThrust), and Column returns nothing + // while the grant is nil. With no power at all the thruster's live thrust is zero, yet the allocator still asks + // it for its throttle (Execute then gates on the grant, and the thruster does nothing). + [Fact] + public void ATotalBrownoutStillAllocatesTheThrusterItsNominalColumn() + { + using var cache = OpenCatalog(new ThrusterData + { + Thrust = Curved(100, 1), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100) + }); + var ship = BuildShip(cache, reactorCharge: 0); + var thruster = ship.GetBehavior(); + ship.MovementDirection = float2(0, -1); + ship.Update(1f); + ship.Update(1f); + Assert.Equal(0f, thruster.Item.PowerSupply, 3); + Assert.Equal(0f, thruster.Thrust, 3); + Assert.True(thruster.Column(thruster.NominalThrust).y < 0f, "the nominal column pushes the reverse thruster's way"); + Assert.True(thruster.Axis > .5f, "the allocator asks for the throttle whatever the grant"); + } + [Fact] public void ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust() { diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 7168b54fa..12b436e56 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -7,11 +7,12 @@ using GameCult.Caching; 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; 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. +// A ThrottleLock on an active consumable is a bound on the allocator's solve (ruling overdrive-forward-floor): the stick +// is ignored, forward is the only translation asked, and every thruster whose live column pushes forward is held at +// full; Turn is served by what remains. Every observation is a thruster's Axis after a real Ship.Update, with the effect +// started through Entity.ActivateConsumable. In the default fixture the one forward drive sits on the centre line, so +// the floor changes nothing there and a locked ship answers as an unlocked twin fed full forward and the same Turn +// through the same ticks; the drive-pair fixture is where the floor shows. public sealed class ThrottleLockTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-throttlelock-" + Guid.NewGuid().ToString("N")); @@ -45,6 +46,7 @@ public sealed class ThrottleLockTests : IDisposable public Ship Ship; public ConsumableItemData Lock; public Thruster Forward, Reverse, Clockwise; + public Thruster[] Drives; // every forward-pushing thruster public Thruster[] Right, Left; // pairs with opposing torque: a lone off-axis strafe thruster cancels its own torque public void ActivateLock() => Ship.ActivateConsumable(new ConsumableItem @@ -57,7 +59,9 @@ public sealed class ThrottleLockTests : IDisposable // One thruster per intent direction, plus two reverse thrusters either side of the centre line, one of // which has clockwise torque (the one Turn reaches). Thrusters are found by their item's rotation, as Ship // sorts them. - private Fixture Build(float lockDuration) + // With `drivePair`, the forward drive on the centre line is replaced by a strong drive and a weak one either side of + // it (their torques differ), so a hard turn has a drive to idle; the front strafe thrusters give way to them. + private Fixture Build(float lockDuration, bool drivePair = false) { var cache = AetheriaStores.Open(Catalog, catalogWritable: true); cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); @@ -65,6 +69,12 @@ public sealed class ThrottleLockTests : IDisposable foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 }); cache.Upsert(new GearData + { + Name = "Weak Thruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, + Behaviors = { new ThrusterData { Thrust = Constant(20), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(0) } } + }); + cache.Upsert(new GearData { Name = "Thruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400, @@ -78,13 +88,22 @@ public sealed class ThrottleLockTests : IDisposable var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName("Skiff")), new EntitySettings()); var gear = cache.GetByName("Thruster"); - Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Reversed), new int2(2, 1))); + var weak = cache.GetByName("Weak Thruster"); + if (drivePair) + { + Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Reversed), new int2(1, 1))); + Assert.True(ship.TryEquip(Mint(cache, items, weak, ItemRotation.Reversed), new int2(3, 1))); + } + else Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Reversed), new int2(2, 1))); Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.None), new int2(1, 2))); Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.None), new int2(3, 2))); // Strafe pairs sit either side of the centre line across the ship's length, so their torques oppose. - Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 1))); + if (!drivePair) + { + Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 1))); + Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 1))); + } Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 3))); - Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 1))); Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 3))); zone.Entities.Add(ship); ship.Aim = float3(0, 0, 1); @@ -96,7 +115,8 @@ public sealed class ThrottleLockTests : IDisposable return new Fixture { Cache = cache, Items = items, Ship = ship, Lock = cache.GetByName("Overdrive"), - Forward = thrusters.Single(t => t.Item.EquippableItem.Rotation == ItemRotation.Reversed), + Drives = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.Reversed).ToArray(), + Forward = thrusters.First(t => t.Item.EquippableItem.Rotation == ItemRotation.Reversed), Right = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.CounterClockwise).ToArray(), Left = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.Clockwise).ToArray(), Reverse = thrusters.First(t => t.Item.EquippableItem.Rotation == ItemRotation.None && t.Torque <= 0), @@ -226,6 +246,58 @@ public sealed class ThrottleLockTests : IDisposable Tick(f, twin, intent, 0, 0.01f); } + private static float Yaw(Fixture f) => f.Ship.GetBehaviors().Sum(t => t.Axis * t.Column(t.NominalThrust).z); + + // Ruling overdrive-forward-floor. Mutations: no floor (the lock is only the intent (0,1)); the floor on a function of + // the column (y above half the strongest forward push, which spares the weak drive); the floor by type. Under the + // lock every thruster whose live column pushes forward holds full through a hard turn either way, the turn is + // slower than the unlocked ship's, the stick is ignored, and a drive that is not live is not floored. + [Theory] + [InlineData(1f)] + [InlineData(-1f)] + public void ALockedHardTurnKeepsEveryForwardDriveAtFull(float turn) + { + var locked = Build(100f, drivePair: true); + var open = Build(100f, drivePair: true); + var blind = Build(100f, drivePair: true); + using var _ = locked.Cache; + using var __ = open.Cache; + using var ___ = blind.Cache; + locked.ActivateLock(); + blind.ActivateLock(); + Assert.Equal(2, locked.Drives.Length); + for (var tick = 0; tick < 3; tick++) + { + locked.Ship.MovementDirection = float2(1, -1); + blind.Ship.MovementDirection = float2(0, 0); + open.Ship.MovementDirection = float2(0, 1); + foreach (var f in new[] { locked, open, blind }) { f.Ship.Turn = turn; f.Ship.Update(0.01f); } + } + Assert.All(locked.Drives, d => Assert.True(d.Axis > .98f, "a forward-pushing thruster holds full under the lock")); + Assert.True(open.Drives.Min(d => d.Axis) < .9f, "fixture: the unlocked ship idles a drive to turn"); + Assert.True(Yaw(locked) * turn < Yaw(open) * turn - 1e-4f, "the locked turn is what remains after the drives"); + Assert.True(Apart(Axes(locked), Axes(blind)) < 1e-6f, "the stick is ignored under the lock"); + } + + [Fact] + public void ALockHoldsOnlyTheDrivesThatPushForward() + { + var f = Build(100f, drivePair: true); + using var _ = f.Cache; + f.ActivateLock(); + var down = f.Drives[0]; + down.Item.Enabled.Value = false; + for (var tick = 0; tick < 3; tick++) + { + f.Ship.MovementDirection = float2(0, 1); + f.Ship.Turn = 1; + f.Ship.Update(0.01f); + } + Assert.False(down.Item.Active.Value); + Assert.Equal(0f, down.Axis); + Assert.True(f.Drives[1].Axis > .98f); + } + // 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] diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index 8e9d0c63d..5ff0b619a 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -131,6 +131,41 @@ public sealed partial class RunStartTests Assert.True(abs(AllocFly(ship, 360)) < .05f); } + // Finding liveness-read-before-performance. Mutation: allocate in Ship.Update before base.Update (the first wiring), so + // liveness and thrust are read before the performance update that shuts a drive down. A drive heated gradually + // until it shuts off is balanced out on the tick it dies, not the tick after: the heading steps no more than the + // disabled path's (0.0002 deg at Enabled = false), the dying drive carries no throttle that tick, and the heading + // held through the heat-up. + [Fact] + public void AThermalShutdownHoldsHeadingOnItsDeathTick() + { + var (_, player) = AllocDuel(); + player.MovementDirection = float2(0, 1); + player.Turn = 0; + for (var tick = 0; tick < 30; tick++) player.Update(AllocDt); + var dying = AllocDrives(player)[^1]; + var start = player.Direction; + var shutdown = -1; + var deathStep = 0f; + var axisOnDeathTick = -1f; + for (var tick = 1; tick <= 600 && shutdown < 0; tick++) + { + foreach (var cell in dying.Item.InsetShape.Coordinates) player.Temperature[cell.x, cell.y] += 4f; + var before = player.Direction; + var wasActive = dying.Item.Active.Value; + player.Update(AllocDt); + if (!wasActive || dying.Item.Active.Value) continue; + shutdown = tick; + deathStep = degrees(SignedTurn(before, player.Direction)); + axisOnDeathTick = dying.Axis; + } + Assert.True(shutdown > 0, "fixture: the heated drive shut down"); + Assert.Equal(0f, axisOnDeathTick); + Assert.True(abs(deathStep) < .005f, $"the death tick stepped the heading {deathStep} deg"); + Assert.True(abs(degrees(SignedTurn(start, player.Direction))) < .05f, "the heading held through the heat-up"); + Assert.True(abs(AllocFly(player, 60)) < .1f); + } + [Fact] public void ARepairedDriveRejoins() { diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 2e293ddb2..dcbaa1986 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -422,6 +422,27 @@ public class ThrustAllocatorTests Assert.InRange(Solve(duel, 0, 0, -.5f)[1], 1.05e-4f, LeakBound); } + [Fact] + public void TheForwardFloorHoldsEveryColumnThatPushesForward() + { + // The throttle lock (ruling overdrive-forward-floor): a column is floored by the sign of its own forward push, not + // by its size or its torque. A sliver of forward push is held at full through a hard turn either way, and the + // columns that push nowhere or back are free. + var hull = new[] { Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(0, .01f, -80f), Col(29.08f, 0, 166.26f), Col(0, -40f, 90f) }; + var allocator = new ThrustAllocator(); + foreach (var turn in new[] { 1f, -1f }) + { + var throttle = new float[hull.Length]; + allocator.Allocate(hull, new float2(1, -1), turn, throttle, forwardFloor: true); + Assert.All(throttle.Take(3), t => Assert.True(t > .9999f, "a forward-pushing column holds full")); + Assert.True(throttle[4] < 1e-3f, "reverse stays off: the stick is ignored under the floor"); + + var open = new float[hull.Length]; + new ThrustAllocator().Allocate(hull, new float2(0, 1), turn, open); + Assert.True(open.Take(3).Min() < .5f, "fixture: unfloored, the turn idles a forward drive"); + } + } + [Fact] public void AMicroIntentIsServed() { From 9ff6e4ba4294bee38a8703f1bb1252760210146d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:39:42 +0200 Subject: [PATCH 14/19] Tests: the floor's stick-independence at the allocator; throttle-lock twin diagnostics Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrottleLockTests.cs | 3 ++- tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 12b436e56..57f5af731 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -137,7 +137,8 @@ public sealed class ThrottleLockTests : IDisposable 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"); + var apart = Apart(Axes(ship), Axes(twin)); + Assert.True(apart < 1e-6f, $"the ship's throttles differ from its full-forward twin's by {apart}: [{string.Join(" ", Axes(ship))}] against [{string.Join(" ", Axes(twin))}]"); Assert.True(twin.Forward.Axis > .98f, "fixture: the twin's forward drive is at full"); } diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index dcbaa1986..405bb8833 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -435,7 +435,11 @@ public class ThrustAllocatorTests var throttle = new float[hull.Length]; allocator.Allocate(hull, new float2(1, -1), turn, throttle, forwardFloor: true); Assert.All(throttle.Take(3), t => Assert.True(t > .9999f, "a forward-pushing column holds full")); - Assert.True(throttle[4] < 1e-3f, "reverse stays off: the stick is ignored under the floor"); + + var blind = new float[hull.Length]; + new ThrustAllocator().Allocate(hull, default, turn, blind, forwardFloor: true); + var apart = throttle.Zip(blind, (a, b) => MathF.Abs(a - b)).Max(); + Assert.True(apart < 1e-6f, $"the stick changed the floored throttles by {apart}"); var open = new float[hull.Length]; new ThrustAllocator().Allocate(hull, new float2(0, 1), turn, open); From 9f779d42c07d08c109aef15e035a3f1c79a04ac0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:42:37 +0200 Subject: [PATCH 15/19] ThrottleLockTests: the twin comparison reads to the solver's precision (1e-4) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ThrottleLockTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 57f5af731..23b02fe17 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -129,7 +129,8 @@ public sealed class ThrottleLockTests : IDisposable 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. + // requires every thruster to answer alike to 1e-4: the lock holds its drive at exactly 1, and the unfloored twin's solve + // lands within the solver's few millionths of it (measured 4e-6). private static void Tick(Fixture ship, Fixture twin, float2 intent, float turn, float dt) { ship.Ship.MovementDirection = intent; @@ -138,7 +139,7 @@ public sealed class ThrottleLockTests : IDisposable ship.Ship.Update(dt); twin.Ship.Update(dt); var apart = Apart(Axes(ship), Axes(twin)); - Assert.True(apart < 1e-6f, $"the ship's throttles differ from its full-forward twin's by {apart}: [{string.Join(" ", Axes(ship))}] against [{string.Join(" ", Axes(twin))}]"); + Assert.True(apart < 1e-4f, $"the ship's throttles differ from its full-forward twin's by {apart}: [{string.Join(" ", Axes(ship))}] against [{string.Join(" ", Axes(twin))}]"); Assert.True(twin.Forward.Axis > .98f, "fixture: the twin's forward drive is at full"); } From 66641b46defa157cf1a2b9750a303fbdf27a9733 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:47:53 +0200 Subject: [PATCH 16/19] ThrustAllocatorTests: throttles stay in bounds and floors hold wherever the solve ends Co-Authored-By: Claude Opus 5.5 --- .../ThrustAllocatorTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 405bb8833..7e36f5569 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs @@ -447,6 +447,34 @@ public class ThrustAllocatorTests } } + [Fact] + public void TheThrottlesStayInBoundsWhateverTheSolveEnds() + { + // The solver can stop at its iteration limit rather than converge. Probed on 64000 calls over 1 to 32 columns, scales + // 1e-6 to 1e6, micro and ordinary intents (2026-10-10): 8 of 60297 solves reached it, all on 32 synthetic columns, all with + // intents near 1e-8, none from a fresh allocator, none on a shipped hull (the largest has 8 thrusters). Wherever the + // solve ends, the allocator hands back what it has: finite, within [0,1], and the floored columns at full. + var rng = new System.Random(20261010); + float Rand(float lo, float hi) => lo + (float) rng.NextDouble() * (hi - lo); + foreach (var scale in new[] { 1e-3f, 1f }) + for (var round = 0; round < 12; round++) + { + var hull = Enumerable.Range(0, 32).Select(_ => new float3(Rand(-1, 1) * scale, Rand(-1, 1) * scale, rng.Next(4) == 0 ? 0 : Rand(-.3f, .3f) * scale)).ToArray(); + var allocator = new ThrustAllocator(); + var throttle = new float[hull.Length]; + for (var call = 0; call < 40; call++) + { + var micro = rng.Next(4) == 0 ? 1e-7f : 1f; + var floor = rng.Next(3) == 0; + allocator.Allocate(hull, new float2(Rand(-1, 1) * micro, Rand(-1, 1) * micro), Rand(-1.5f, 1.5f) * micro, throttle, floor); + Assert.All(throttle, t => Assert.InRange(t, 0f, 1f)); + if (floor) + for (var i = 0; i < hull.Length; i++) + if (hull[i].y > 0) Assert.Equal(1f, throttle[i]); + } + } + } + [Fact] public void AMicroIntentIsServed() { From 5a6e81db7589b7417df314d6cd97d3dfa3a0a19b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:23:27 +0200 Subject: [PATCH 17/19] wip reach probe --- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index a399f6ce9..009c55667 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -243,10 +243,29 @@ public sealed partial class RunStartTests return ((right + left) / 2, (forward + back) / 2); } - private static (float noseOn, float broadside) EvReach(Ship ship, float window) + // What the allocator can hold the heading and still push, per half-axis, from the live columns the ship allocates + // with: the allocator's net push for a full-stick intent on each axis with no turn asked. The turn limits stay the + // envelope's. + private static ManoeuvreEnvelope EvAllocatedEnvelope(Ship ship) + { + var columns = ship.GetBehaviors().Select(t => t.Column(t.NominalThrust)).ToArray(); + var throttles = new float[columns.Length]; + float Push(float2 move, float2 axis) + { + new ThrustAllocator().Allocate(columns, move, 0f, throttles); + var net = float2(0, 0); + for (var i = 0; i < columns.Length; i++) net += columns[i].xy * throttles[i]; + return dot(net, axis); + } + var e = ship.Envelope; + return new ManoeuvreEnvelope(Push(float2(0, 1), float2(0, 1)), Push(float2(0, -1), float2(0, -1)), + Push(float2(-1, 0), float2(-1, 0)), Push(float2(1, 0), float2(1, 0)), e.Clockwise, e.CounterClockwise); + } + + private static (float noseOn, float broadside) EvReach(Ship ship, float window, ManoeuvreEnvelope envelope) { var heading = float2(0, 1); - float Half(float2 n) => .5f * (FireControl.Reach(ship.Envelope, heading, n, window) + FireControl.Reach(ship.Envelope, heading, -n, window)); + float Half(float2 n) => .5f * (FireControl.Reach(envelope, heading, n, window) + FireControl.Reach(envelope, heading, -n, window)); return (Half(float2(1, 0)), Half(float2(0, 1))); } @@ -261,7 +280,9 @@ public sealed partial class RunStartTests var measured = EvMeasuredHalfWidths(ship, window); EvSettle(ship); ship.Update(EvDt); - var reach = EvReach(ship, window); + var reach = EvReach(ship, window, ship.Envelope); + var allocated = EvReach(ship, window, EvAllocatedEnvelope(ship)); + Console.WriteLine($"ALLOC {name}: allocated nose-on {allocated.noseOn:F3} broadside {allocated.broadside:F3}"); Console.WriteLine($"REACH {name}: nose-on reach {reach.noseOn:F3} measured {measured.noseOn:F3}; broadside reach {reach.broadside:F3} measured {measured.broadside:F3}"); results.Add((name, reach, measured)); } From e1788e19c11ab12033a678bbc59f482a4a9c3960 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:32:18 +0200 Subject: [PATCH 18/19] Tests: the Longinus's reach bound is derived from the allocator's own push, not a widened constant; stale mixer comments and unused using UniRx removed Builds; not yet run (the run follows). --- Assets/Scripts/ServerShared/Ship.cs | 1 - .../Aetheria.Shared.Tests/EvasionTermTests.cs | 47 +++++++++++-------- .../RestoredHullsTests.cs | 12 ++--- .../ThrottleLockTests.cs | 4 +- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index a7339c4e1..6a3f9ea44 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Reflection; using MessagePack; using Newtonsoft.Json; -using UniRx; using CultMath; using static CultMath.math; using quaternion = CultMath.quaternion; diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 009c55667..bd2e6d645 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -262,7 +262,7 @@ public sealed partial class RunStartTests Push(float2(-1, 0), float2(-1, 0)), Push(float2(1, 0), float2(1, 0)), e.Clockwise, e.CounterClockwise); } - private static (float noseOn, float broadside) EvReach(Ship ship, float window, ManoeuvreEnvelope envelope) + private static (float noseOn, float broadside) EvReach(ManoeuvreEnvelope envelope, float window) { var heading = float2(0, 1); float Half(float2 n) => .5f * (FireControl.Reach(envelope, heading, n, window) + FireControl.Reach(envelope, heading, -n, window)); @@ -274,33 +274,42 @@ public sealed partial class RunStartTests { var (_, longinus, djinni) = EvFleet(); const float window = .5f; - var results = new List<(string name, (float noseOn, float broadside) reach, (float noseOn, float broadside) measured)>(); + var results = new List<(string name, (float noseOn, float broadside) reach, (float noseOn, float broadside) allocated, (float noseOn, float broadside) measured)>(); foreach (var (name, ship) in new[] { ("longinus", longinus), ("djinni", djinni) }) { var measured = EvMeasuredHalfWidths(ship, window); EvSettle(ship); ship.Update(EvDt); - var reach = EvReach(ship, window, ship.Envelope); - var allocated = EvReach(ship, window, EvAllocatedEnvelope(ship)); + var reach = EvReach(ship.Envelope, window); + var allocated = EvReach(EvAllocatedEnvelope(ship), window); Console.WriteLine($"ALLOC {name}: allocated nose-on {allocated.noseOn:F3} broadside {allocated.broadside:F3}"); Console.WriteLine($"REACH {name}: nose-on reach {reach.noseOn:F3} measured {measured.noseOn:F3}; broadside reach {reach.broadside:F3} measured {measured.broadside:F3}"); - results.Add((name, reach, measured)); + results.Add((name, reach, allocated, measured)); } - // The reach is the capability and the measured motion is the best of fifteen fixed control policies, so reach may - // sit above it: by up to 15% for the Longinus, whose controller shapes its turn input (a square root of the - // remaining angle) where the reach turns at full rate. - foreach (var (name, reach, measured) in results) + // The envelope's reach is the capability and the measured motion is the best of fifteen fixed control policies, so + // the reach covers it: no more than 10% below it. + // The Djinni's measured motion sits further below its reach (+38% nose-on, +26% broadside at the last measurement): + // its strafers are off-axis and the envelope, summed from what each thruster can push, does not model that. Its + // allocator holds the whole box (its allocated reach is the envelope's), so the allocated reach says nothing more + // about it. The bound still pins that the reach covers what the ship does. + // The Longinus's allocator holds the heading and pays for the drive torque out of the flank thrusters (ruling + // allocator-divergence-accepted), so the envelope's box support overstates what a controlled flight spends. Its + // measured motion is bounded by the reach of the push the allocator itself gives it on each half-axis, from the + // columns it allocates with: not below 90% of it, and above it by no more than the 15% a turning policy gains. + foreach (var (name, reach, allocated, measured) in results) { - // The Djinni's measured motion is further below its reach (+53% nose-on, +27% broadside at the last measurement): its - // strafers are off-axis and Ship.Update cancels their torque (M24), which the envelope, summed from what each thruster - // can push, does not model. Stated deviation; the bound still pins that the reach covers what the ship does. - // The Longinus's reach sits 19.5% (nose-on) and 24.9% (broadside) above what the allocator's flight measures - // (ruling allocator-divergence-accepted): the allocator holds the heading and pays for the drive torque out of - // the flank thrusters, so the envelope's box support (cut envelope-from-columns reads the allocator's columns) - // promises more than a controlled flight spends. - var ceiling = name == "djinni" ? 1.6f : 1.3f; - Assert.InRange(reach.noseOn, measured.noseOn * .9f, measured.noseOn * ceiling); - Assert.InRange(reach.broadside, measured.broadside * .9f, measured.broadside * ceiling); + if (name == "djinni") + { + Assert.InRange(reach.noseOn, measured.noseOn * .9f, measured.noseOn * 1.6f); + Assert.InRange(reach.broadside, measured.broadside * .9f, measured.broadside * 1.6f); + } + else + { + Assert.InRange(reach.noseOn, measured.noseOn * .9f, float.MaxValue); + Assert.InRange(reach.broadside, measured.broadside * .9f, float.MaxValue); + Assert.InRange(measured.noseOn, allocated.noseOn * .9f, allocated.noseOn * 1.15f); + Assert.InRange(measured.broadside, allocated.broadside * .9f, allocated.broadside * 1.15f); + } } } diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 0dba1a567..b0235e840 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -19,14 +19,14 @@ using Random = CultMath.Random; // content restore-hulls landed, against the real shipped catalog, read-only: the hulls exist with the expected // thruster hardpoint counts, every thruster hardpoint accepts a catalog thruster design through TryEquip at its // own position, and the ship then moves and turns for a few ticks under MovementDirection/Turn on the -// pre-Cut-3 mixer -- proof of the restored content, not of a controller this cut does not touch. +// thrust allocator -- proof of the restored content, not of a controller this cut does not touch. public sealed class RestoredHullsTests { // Soul's finding #1 (this cut's fix batch 3): a hand-set GameplaySettings fixture left thermal radiation and // conduction at their bare-class defaults (1/1/1) against the shipped 3 / 1e-8 / 0.01 // (Assets/Resources/Settings.asset), so equipped parts froze toward 0 K, performance collapsed to 0 from // cold, and TorqueMultiplier defaulting to 0 on top of that made every thruster's yaw contribution silently - // zero (Ship.cs's mixer applies Torque*Thrust*TorqueMultiplier/Mass) -- velocity moved but Direction never + // zero (Thruster.Column applies Torque*Thrust*TorqueMultiplier/Mass) -- velocity moved but Direction never // turned, which looks exactly like a content defect instead of a fixture gap. AuthoredSettings // (tools/AetherDb/AuthoredSettings.cs) is the same headless YAML reader tools/AetherDb already uses to // inspect Settings.asset outside Unity; this loads the real GameplaySettings the same way instead of @@ -324,7 +324,7 @@ public sealed class RestoredHullsTests // "The ship then moves and turns... for a few ticks" (docs/locomotion-cut.md verification list). Equips // every thruster hardpoint with a fitting design, then drives MovementDirection/Turn the same way // ActionGameManager's player-input path does (docs' own consumer audit) and checks Entity.Velocity and - // Entity.Direction both move over a few ticks on the current (pre-Cut-3) mixer. + // Entity.Direction both move over a few ticks under the thrust allocator. [Theory] [InlineData("Longinus")] [InlineData("Djinni")] @@ -338,7 +338,7 @@ public sealed class RestoredHullsTests var startDirection = ship.Direction; ship.MovementDirection = float2(0, 1); // full forward, same axis ActionGameManager.Input.Player.Move drives - ship.Turn = 1; // a turn command, so the mixer's yaw thrusters have work to do + ship.Turn = 1; // a turn command, so the allocator's yaw thrusters have work to do for (var i = 0; i < 10; i++) zone.Update(1f / 60f); Assert.NotEqual(startVelocity, ship.Velocity); @@ -587,8 +587,8 @@ public sealed class RestoredHullsTests // flying: a real, unforced Longinus holds thrust (and the hull's own VelocityLimit) running well past the // window this test drives. // - // Real settings also retired the old +-10 tolerance's premise. On real physics the mixer's own tick order - // (Ship.cs: a tick's thrust-driven velocity is checked against VelocityLimit's cap on the FOLLOWING tick) + // Real settings also retired the old +-10 tolerance's premise. On real physics the tick order + // (Entity: a tick's thrust-driven velocity is checked against VelocityLimit's cap on the FOLLOWING tick) // settles into a steady state at the cap plus one tick's own forward acceleration, not at the literal cap -- // measured here at ~103.3 for this seed's generated loadout, not the ~110 an unconstrained +-10 tolerance // would also accept. +4 keeps that real headroom on the ceiling side. The floor is tighter (-3): a diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 23b02fe17..38c883a6c 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -57,8 +57,8 @@ public sealed class ThrottleLockTests : IDisposable } // One thruster per intent direction, plus two reverse thrusters either side of the centre line, one of - // which has clockwise torque (the one Turn reaches). Thrusters are found by their item's rotation, as Ship - // sorts them. + // which has clockwise torque (the one Turn reaches). Thrusters are found by their item's rotation, as the + // allocator's columns carry them. // With `drivePair`, the forward drive on the centre line is replaced by a strong drive and a weak one either side of // it (their torques differ), so a hard turn has a drive to idle; the front strafe thrusters give way to them. private Fixture Build(float lockDuration, bool drivePair = false) From 91fe002596405378c21a03952bf07a1bf4b05454 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:19:37 +0200 Subject: [PATCH 19/19] ThrottleLock comment describes the floor; test pins the wormhole gate on the allocator Builds: not yet verified at this commit. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/ThrottleLock.cs | 6 ++--- .../ThrustAllocationTests.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs b/Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs index d33ef07cb..52fcc926f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs @@ -5,9 +5,9 @@ using MessagePack; using Newtonsoft.Json; -// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and Ship.Update -// then reads full forward intent in place of MovementDirection. The behaviour holds no state and does nothing -// itself; turning stays with the pilot. +// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and the thrust +// allocator then bounds every forward-pushing column at full throttle from below in its solve. The behaviour +// holds no state and does nothing itself; turning stays with the pilot. [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class ThrottleLockData : BehaviorData { diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index 5ff0b619a..f5f035496 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -252,4 +252,31 @@ public sealed partial class RunStartTests Assert.True(previous > 10f, "fixture: the ship got going"); Assert.True(abs(degrees(SignedTurn(start, player.Direction))) < .1f); } + + // The allocator runs while the ship flies and not while a wormhole animation scripts it: the throttles a ship + // carries into the animation are the ones it keeps, whatever the pilot asks meanwhile. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TheThrottlesFreezeForAWormholeAnimation(bool entering) + { + var (_, player) = AllocDuel(); + var thrusters = player.GetBehaviors().ToArray(); + player.MovementDirection = float2(0, 1); + player.Turn = 0; + for (var tick = 0; tick < 30; tick++) player.Update(AllocDt); + var flying = thrusters.Select(t => t.Axis).ToArray(); + Assert.True(flying.Max() > .5f, "fixture: the allocator drives the thrusters while the ship flies"); + + if (entering) player.EnterWormhole(player.Position.xz + float2(50, 0)); + else player.ExitWormhole(player.Position.xz + float2(50, 0), float2(0, 20)); + player.MovementDirection = float2(0, 0); + player.Turn = 1; + for (var tick = 0; tick < 5; tick++) + { + player.Update(AllocDt); + Assert.True(player.WormholeAnimationInProgress, "fixture: the animation is still running"); + Assert.Equal(flying, thrusters.Select(t => t.Axis).ToArray()); + } + } }