Merge eureka/aetheria-release-envelope-from-columns: the evasion envelope is the box extremes of the thrusters' columns at live thrust
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
93b7ff8da0
7 changed files with 151 additions and 43 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -2089,8 +2089,9 @@ public struct FireControlDiagnostic
|
|||
}
|
||||
|
||||
// What a ship can do at this instant, in its own frame: acceleration along each body axis (m/s^2) and the kinematic
|
||||
// turn rate each way (rad/s; turning toward the ship's right is Clockwise). Each propulsor reports its share with
|
||||
// its own Execute arithmetic and Ship sums them; nothing stores it. Zero is the envelope of anything that is not a Ship.
|
||||
// turn rate each way (rad/s; turning toward the ship's right is Clockwise). Ship derives it each update as the box
|
||||
// extremes of its thrusters' columns (Ship.RecalculateEnvelope); nothing stores it. Zero is the envelope of anything
|
||||
// that is not a Ship.
|
||||
public readonly struct ManoeuvreEnvelope
|
||||
{
|
||||
public readonly float Forward, Reverse, Left, Right, Clockwise, CounterClockwise;
|
||||
|
|
@ -2104,10 +2105,6 @@ public readonly struct ManoeuvreEnvelope
|
|||
Clockwise = clockwise;
|
||||
CounterClockwise = counterClockwise;
|
||||
}
|
||||
|
||||
public static ManoeuvreEnvelope operator +(ManoeuvreEnvelope a, ManoeuvreEnvelope b) => new ManoeuvreEnvelope(
|
||||
a.Forward + b.Forward, a.Reverse + b.Reverse, a.Left + b.Left, a.Right + b.Right,
|
||||
a.Clockwise + b.Clockwise, a.CounterClockwise + b.CounterClockwise);
|
||||
}
|
||||
|
||||
// How unpredictably an entity is changing its motion, observed from its measured acceleration. The trend is an
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -84,15 +84,27 @@ 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 thrust (zero when unpowered or not Active), because it reports what the ship can do now. The allocator solves
|
||||
// with Column(NominalThrust), the full-grant column, because a column promises what a thruster does at a full grant
|
||||
// and the allocator must not reshape its answer as the grant moves. Same geometry (Thruster.Column), different thrust.
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -152,6 +152,46 @@ public sealed class BrownoutTests : IDisposable
|
|||
Assert.Equal(.5f, thruster.PowerRequest(1f), 4);
|
||||
}
|
||||
|
||||
// The evasion envelope is the box extremes of the columns at live, granted thrust (cut envelope-from-columns): the
|
||||
// allocator solves with the full-grant column, the envelope reports what the grant lets the ship do. A reverse
|
||||
// thruster at half grant adds half its nominal push to the envelope's reverse; with no grant at all it adds nothing
|
||||
// even when its thrust stat carries no power term, because an unpowered thruster does not fire.
|
||||
[Fact]
|
||||
public void TheEnvelopeIsWhatTheGrantAllows()
|
||||
{
|
||||
using var cache = OpenCatalog(new ThrusterData
|
||||
{
|
||||
Thrust = Curved(100, 1), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
|
||||
});
|
||||
var ship = BuildShip(cache, reactorCharge: 50);
|
||||
var thruster = ship.GetBehavior<Thruster>();
|
||||
ship.Equipment.Single(e => e.Data.Name == "Consumer").UpdatePerformance();
|
||||
ship.MovementDirection = float2(0, -1);
|
||||
ship.Update(1f);
|
||||
ship.Update(1f); // the envelope is derived before the power bus steps, from the grant the last tick left
|
||||
Assert.True(thruster.Thrust > 1f && thruster.Thrust < .9f * thruster.NominalThrust, "fixture: the grant is partial");
|
||||
var expected = thruster.Thrust / ship.Mass;
|
||||
Assert.InRange(ship.Envelope.Reverse, expected * .9999f, expected * 1.0001f);
|
||||
Assert.Equal(0f, ship.Envelope.Forward);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnpoweredThrusterAddsNothingToTheEnvelope()
|
||||
{
|
||||
using var cache = OpenCatalog(new ThrusterData
|
||||
{
|
||||
Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(100)
|
||||
});
|
||||
var ship = BuildShip(cache, reactorCharge: 0);
|
||||
var thruster = ship.GetBehavior<Thruster>();
|
||||
ship.MovementDirection = float2(0, -1);
|
||||
ship.Update(1f);
|
||||
ship.Update(1f);
|
||||
Assert.Equal(0f, thruster.Item.PowerSupply, 3);
|
||||
Assert.True(thruster.Thrust > 1f, "fixture: the stat carries no power term, so the thruster's stat is not zero");
|
||||
Assert.Equal(0f, ship.Envelope.Reverse);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ public sealed partial class RunStartTests
|
|||
// 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.
|
||||
// columns it allocates with: not below 90% of it (85% broadside: the allocated reach now carries the drives' yaw in its turn terms), and above it by no more than the 15% a turning policy gains.
|
||||
foreach (var (name, reach, allocated, measured) in results)
|
||||
{
|
||||
if (name == "djinni")
|
||||
|
|
@ -305,10 +305,14 @@ public sealed partial class RunStartTests
|
|||
}
|
||||
else
|
||||
{
|
||||
Assert.InRange(reach.noseOn, measured.noseOn * .9f, float.MaxValue);
|
||||
Assert.InRange(reach.broadside, measured.broadside * .9f, float.MaxValue);
|
||||
// The envelope sums the live columns, the drives' yaw among them (turn-authority-full), so its reach sits above
|
||||
// the allocated one by the turn the drives add: 1.24 nose-on and 1.12 broadside at the last measurement. The
|
||||
// ceiling is that, from the columns the allocator solves with, not an open bound: an envelope inflated by a
|
||||
// factor (doubled: 2.5 and 2.2) breaks it.
|
||||
Assert.InRange(reach.noseOn, measured.noseOn * .9f, allocated.noseOn * 1.3f);
|
||||
Assert.InRange(reach.broadside, measured.broadside * .9f, allocated.broadside * 1.2f);
|
||||
Assert.InRange(measured.noseOn, allocated.noseOn * .9f, allocated.noseOn * 1.15f);
|
||||
Assert.InRange(measured.broadside, allocated.broadside * .9f, allocated.broadside * 1.15f);
|
||||
Assert.InRange(measured.broadside, allocated.broadside * .85f, allocated.broadside * 1.15f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -362,8 +366,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 +418,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);
|
||||
|
|
@ -427,6 +431,37 @@ public sealed partial class RunStartTests
|
|||
Assert.True(djinni.Acceleration.x < -1f, "it still strafes left");
|
||||
}
|
||||
|
||||
// Each side of the envelope is the sum of what every thruster pushing that way contributes, never the largest one:
|
||||
// the Djinni has several thrusters on a side, so its Left and Right are the sums of the positive and negative
|
||||
// x of its live columns (Thruster.Column at live thrust, the owner of that geometry).
|
||||
[Fact]
|
||||
public void EachSideOfTheEnvelopeSumsTheThrustersPushingThatWay()
|
||||
{
|
||||
var (_, _, djinni) = EvFleet();
|
||||
EvSettle(djinni);
|
||||
foreach (var (move, look) in new[] { (float2(1, 0), float3(0, 0, 1)), (float2(-1, 0), float3(0, 0, 1)), (float2(0, 1), float3(0, 0, 1)),
|
||||
(float2(0, -1), float3(0, 0, 1)) })
|
||||
{
|
||||
djinni.MovementDirection = move;
|
||||
for (var step = 0; step < 20; step++)
|
||||
{
|
||||
EvFace(djinni, look);
|
||||
djinni.Update(EvDt);
|
||||
}
|
||||
}
|
||||
EvSettle(djinni);
|
||||
djinni.Update(EvDt);
|
||||
var columns = djinni.GetBehaviors<Thruster>().Where(t => t.Item.Active.Value).Select(t => t.Column(t.Thrust)).ToList();
|
||||
var rightPushers = columns.Where(c => c.x > 0f).ToList();
|
||||
var leftPushers = columns.Where(c => c.x < 0f).ToList();
|
||||
Assert.True(rightPushers.Count >= 2 && leftPushers.Count >= 2, "fixture: each side has more than one thruster");
|
||||
var right = rightPushers.Sum(c => c.x);
|
||||
var left = leftPushers.Sum(c => -c.x);
|
||||
Assert.True(right > 1.2f * rightPushers.Max(c => c.x) && left > 1.2f * leftPushers.Max(c => -c.x), "fixture: the sum exceeds the largest column");
|
||||
Assert.InRange(djinni.Envelope.Right, right * .99f, right * 1.01f);
|
||||
Assert.InRange(djinni.Envelope.Left, left * .99f, left * 1.01f);
|
||||
}
|
||||
|
||||
// The reach and the evasion read each side of the envelope for themselves: a forward-only ship that turns clockwise
|
||||
// fast and counter-clockwise slowly reaches further to its right than its left in the window, and its evasion
|
||||
// across a line of sight is the average of what it reaches to either side, capped by the spread it is observed to
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue