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/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/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 007bf66bc..ff4ce05cf 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -37,12 +37,24 @@ 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); + // 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, IAnalogBehavior, 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, IAnalogBehavior, 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/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 59bc38cc9..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) { @@ -2356,6 +2361,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..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; @@ -30,14 +29,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,23 +44,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 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; @@ -95,49 +74,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() @@ -147,120 +95,23 @@ 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 + // 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) - { - RecalculateThrust(); - // 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; - } - 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. diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index 7a4c75a50..3848d5592 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -109,7 +109,9 @@ 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, 1))); zone.Entities.Add(ship); // Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the @@ -129,11 +131,49 @@ 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()); } + // 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); + } + + // 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() { @@ -336,9 +376,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 a05234705..83da4a488 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -92,12 +92,15 @@ 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, 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 9723cad90..bd2e6d645 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(ManoeuvreEnvelope envelope, float window) { 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))); } @@ -255,27 +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); + 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. - var ceiling = name == "djinni" ? 1.6f : 1.15f; - 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); + } } } @@ -712,17 +746,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 +1041,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/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index dbace6890..e2ac85f49 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 @@ -264,6 +264,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, and the first hardpoint of each type in // alsoFill) 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, params HardpointType[] alsoFill) @@ -313,7 +323,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")] @@ -327,7 +337,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); @@ -576,8 +586,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/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..d2abbbbaa 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,17 +278,19 @@ 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(); + RestoredHullsTests.FreeThrusterPower(cache); var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); var settings = RestoredHullsTests.Settings(); const float dt = 1f / 60f; @@ -299,16 +301,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 2e44ea87f..58c1fe356 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -7,9 +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. Turn is untouched. Every observation is a thruster's Axis after a real -// Ship.Update, with the effect started through Entity.ActivateConsumable. +// 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")); @@ -22,7 +25,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 }; @@ -42,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 @@ -52,9 +57,11 @@ 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) + // 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) { var cache = AetheriaStores.Open(Catalog, catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); @@ -62,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, @@ -75,23 +88,35 @@ 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); 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 { 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), @@ -99,59 +124,75 @@ 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 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; + twin.Ship.MovementDirection = float2(0, 1); + ship.Ship.Turn = twin.Ship.Turn = turn; + ship.Ship.Update(dt); + twin.Ship.Update(dt); + var apart = Apart(Axes(ship), 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"); + } + + // 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 +200,38 @@ 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 > .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. + 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++) + 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 +240,94 @@ 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. + 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] 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 +336,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 +354,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..f5f035496 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -0,0 +1,282 @@ +/* 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 UniRx; +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); + 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")); + } + + [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); + + // 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; + 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); + } + + [Fact] + 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); + 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; + // Six seconds: the ship has no heatsinks, and its drives overheat and shut down at about seven. + 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() + { + 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); + } + + // 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()); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs index 95ca77bda..7e36f5569 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; @@ -420,4 +421,84 @@ 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 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")); + + 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); + Assert.True(open.Take(3).Min() < .5f, "fixture: unfloored, the turn idles a forward drive"); + } + } + + [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() + { + // 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"); + } + } }