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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 00:14:31 +02:00
commit db3103a026
5 changed files with 67 additions and 33 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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<Thruster>().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);

View file

@ -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<Thruster>().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<Thruster> 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");
}
}