From db3103a026b677c30f4dfaec2157694d073c9d4d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:14:31 +0200 Subject: [PATCH] Envelope from columns: Ship.RecalculateEnvelope sums the thruster columns' box extremes; Thruster.Manoeuvre and TorqueFloor deleted Unverified at this commit (first build still to run); ceiling test not yet restored. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/Thruster.cs | 23 --------- Assets/Scripts/ServerShared/Settings.cs | 1 - Assets/Scripts/ServerShared/Ship.cs | 20 ++++++-- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 8 ++-- .../ThrustAllocationTests.cs | 48 +++++++++++++++++++ 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index ff4ce05cf..2388bb1c0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -102,29 +102,6 @@ public class Thruster : Behavior, IPowerConsumer // under brownout is an inconvenience, not the cascading failure a starved radiator or shield causes. public int DefaultPowerTier => PowerTiers.Medium; - // What this thruster adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input, read live. - // Its push is Thrust / Mass along its mount, into the matching body - // axis, even for a flank thruster that only turns (it pushes whenever it turns); its turn is |Torque| * Thrust * - // TorqueMultiplier / Mass into the side its Torque sign fires on, when |Torque| clears the floor. Zero when the - // item is offline or unpowered, the same gates Execute applies. - public ManoeuvreEnvelope Manoeuvre() - { - if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default; - var settings = ItemManager.GameplaySettings; - var thrust = Thrust; - var acceleration = thrust / Entity.Mass; - var forward = normalize(Entity.Direction); - var right = forward.Rotate(ItemRotation.Clockwise); - var push = -forward.Rotate(Item.EquippableItem.Rotation); - var along = dot(push, forward); - var across = dot(push, right); - var turn = abs(Torque) > settings.TorqueFloor ? abs(Torque) * thrust * settings.TorqueMultiplier / Entity.Mass : 0f; - return new ManoeuvreEnvelope( - max(along, 0f) * acceleration, max(-along, 0f) * acceleration, - max(-across, 0f) * acceleration, max(across, 0f) * acceleration, - Torque > 0f ? turn : 0f, Torque < 0f ? turn : 0f); - } - public override bool Execute(float dt) { Item.SetAudioParameter(SpecialAudioParameter.Intensity, _input); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 730ee3700..9c4bd459f 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -211,7 +211,6 @@ public class GameplaySettings public float LockIndicatorNoiseAmplitude = 50f; public ExponentialLerp LockIndicatorFrequency; public ExponentialLerp LockSpinSpeed; - public float TorqueFloor; public float TorqueMultiplier; public float VisibilityDecay; public float TargetInfoDecay; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 6a3f9ea44..00e77b4f3 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -84,15 +84,25 @@ public class Ship : Entity #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. + // What this ship can do at this instant (see ManoeuvreEnvelope): the box extremes of the thrusters' columns at their + // live, granted thrust (zero when unpowered or not Active), the same columns the allocator solves with. Derived each + // update, never saved. public ManoeuvreEnvelope Envelope { get; private set; } private void RecalculateEnvelope() { - var envelope = default(ManoeuvreEnvelope); - foreach (var thruster in _allThrusters) envelope += thruster.Manoeuvre(); - Envelope = envelope; + float forward = 0, back = 0, left = 0, right = 0, clockwise = 0, counterClockwise = 0; + foreach (var thruster in _allThrusters) + { + var column = thruster.Column(thruster.Item != null && thruster.Item.PowerSupply > 1e-4f ? thruster.Thrust : 0f); + forward += max(column.y, 0f); + back += max(-column.y, 0f); + left += max(-column.x, 0f); + right += max(column.x, 0f); + clockwise += max(column.z, 0f); + counterClockwise += max(-column.z, 0f); + } + Envelope = new ManoeuvreEnvelope(forward, back, left, right, clockwise, counterClockwise); } #endregion diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index bd2e6d645..cb9558a67 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -362,8 +362,8 @@ public sealed partial class RunStartTests var sides = new[] { envelope.Left, envelope.Right }.OrderBy(a => a).ToArray(); Assert.InRange(sides[0], flankAccelerations[0] * .99f, flankAccelerations[0] * 1.01f); Assert.InRange(sides[1], flankAccelerations[1] * .99f, flankAccelerations[1] * 1.01f); - var clockwise = thrusters.Where(t => t.Torque > settings.TorqueFloor).Sum(t => t.Torque * t.Thrust) * settings.TorqueMultiplier / mass; - var counterClockwise = thrusters.Where(t => t.Torque < -settings.TorqueFloor).Sum(t => -t.Torque * t.Thrust) * settings.TorqueMultiplier / mass; + var clockwise = thrusters.Where(t => t.Torque > 0f).Sum(t => t.Torque * t.Thrust) * settings.TorqueMultiplier / mass; + var counterClockwise = thrusters.Where(t => t.Torque < 0f).Sum(t => -t.Torque * t.Thrust) * settings.TorqueMultiplier / mass; Assert.InRange(envelope.Clockwise, clockwise * .99f, clockwise * 1.01f); Assert.InRange(envelope.CounterClockwise, counterClockwise * .99f, counterClockwise * 1.01f); Assert.True(clockwise > 1f && counterClockwise > 1f, "the flank thrusters turn the Longinus"); @@ -414,8 +414,8 @@ public sealed partial class RunStartTests Assert.Equal(0f, maimed.Right); Assert.InRange(maimed.Left, whole.Left * .99f, whole.Left * 1.01f); var live = djinni.GetBehaviors().Where(t => t.Item.Active.Value).ToList(); - var clockwise = live.Where(t => t.Torque > settings.TorqueFloor).Sum(t => t.Torque * t.Thrust) * settings.TorqueMultiplier / djinni.Mass; - var counterClockwise = live.Where(t => t.Torque < -settings.TorqueFloor).Sum(t => -t.Torque * t.Thrust) * settings.TorqueMultiplier / djinni.Mass; + var clockwise = live.Where(t => t.Torque > 0f).Sum(t => t.Torque * t.Thrust) * settings.TorqueMultiplier / djinni.Mass; + var counterClockwise = live.Where(t => t.Torque < 0f).Sum(t => -t.Torque * t.Thrust) * settings.TorqueMultiplier / djinni.Mass; Assert.True(Math.Abs(clockwise - counterClockwise) > .05f * clockwise, "fixture: the surviving thrusters turn the two ways unequally"); Assert.InRange(maimed.Clockwise, clockwise * .99f, clockwise * 1.01f); Assert.InRange(maimed.CounterClockwise, counterClockwise * .99f, counterClockwise * 1.01f); diff --git a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs index f5f035496..691036188 100644 --- a/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrustAllocationTests.cs @@ -279,4 +279,52 @@ public sealed partial class RunStartTests Assert.Equal(flying, thrusters.Select(t => t.Axis).ToArray()); } } + + // Capability is one model (ruling turn-authority-full): the envelope's turn is what a full command achieves. The + // Duel's Longinus turns in place under Turn +1 then -1, stepped with its own Update (heading only), and the mean turn + // rate over ticks 10-60 is the envelope's clockwise and counter-clockwise. + [Fact] + public void TheEnvelopeIsWhatAFullCommandAchieves() + { + var (_, player) = AllocDuel(); + player.MovementDirection = float2(0, 0); + foreach (var (turn, side) in new[] { (1f, "clockwise"), (-1f, "counter-clockwise") }) + { + player.Turn = turn; + var sum = 0f; + var envelopeSum = 0f; + for (var tick = 0; tick < 60; tick++) + { + player.Update(AllocDt); + if (tick < 10) continue; + sum += abs(player.TurnRate); + envelopeSum += turn > 0 ? player.Envelope.Clockwise : player.Envelope.CounterClockwise; + } + Assert.True(envelopeSum > 0f, $"fixture: the {side} envelope is not empty"); + Assert.InRange(sum, envelopeSum * .99f, envelopeSum * 1.01f); + } + } + + // Rulings evasion-is-unpredictability and turn-authority-full: the Longinus's Large Drives count toward its turn, + // because the envelope is summed over every live column. Its clockwise is the sum of the positive yaw of its columns + // at live thrust, and exceeds the Talaria's own by the drives'. + [Fact] + public void TheEnvelopeCountsTheMains() + { + var (_, player) = AllocDuel(); + player.MovementDirection = float2(0, 0); + player.Turn = 0; + for (var tick = 0; tick < 10; tick++) player.Update(AllocDt); + var thrusters = player.GetBehaviors().Where(t => t.Item.Active.Value).ToArray(); + var drives = thrusters.Where(t => t.Item.Data.Name == "Large Drive").ToArray(); + var flanks = thrusters.Where(t => t.Item.Data.Name == "Talaria").ToArray(); + Assert.Equal(2, drives.Length); + Assert.Equal(2, flanks.Length); + float Clockwise(IEnumerable of) => of.Sum(t => max(t.Column(t.Thrust).z, 0f)); + var expected = Clockwise(thrusters); + Assert.InRange(player.Envelope.Clockwise, expected * (1 - 1e-4f), expected * (1 + 1e-4f)); + Assert.True(Clockwise(drives) > 1f, "fixture: the Large Drives turn the Longinus"); + Assert.True(player.Envelope.Clockwise > Clockwise(flanks) + .9f * Clockwise(drives), + "the mains' turn is in the envelope, over the flank thrusters' own"); + } }