Evasion r6: drop the aether drive from the envelope; the gemini fixtures fly thruster hulls

Ruling retire-longinusx. AetherDrive.Manoeuvre and the drive line in Ship.RecalculateEnvelope go: the envelope
sums thrusters only. TheDrivesReportReadsItsStateAndTheShipsSpeed, the EvGemini fixture and the drive-only asserts
are deleted. Sideways jinks and the shooters' target move to the generated Djinni; the gun platforms (FastBlast+-,
ColdFire) are EvLonginus, the Duel's guns on a Longinus. TheThreeShipsOrder becomes TheShipsOrder.

Not built or run on its own: verified together with the mount term at the head.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 16:39:45 +02:00
commit e360ceeb27
3 changed files with 77 additions and 173 deletions

View file

@ -126,31 +126,6 @@ public class AetherDrive : Behavior, IPowerConsumer
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility, same as Thruster. // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility, same as Thruster.
public int DefaultPowerTier => PowerTiers.Medium; public int DefaultPowerTier => PowerTiers.Medium;
// What this drive adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input on every axis,
// as a rate per second at the rotors' current state (the rotors spend what they hold, so a drive that has been
// thrusting reports less). Each axis delivers Rpm * lambda * RotorMass * efficiency / Mass, the efficiency
// falling as the ship's speed along that axis approaches the rotor speed, so the push along the current
// velocity is smaller than against it; the turn is the z axis through AetherTorqueMultiplier, the same both
// ways. Zero when the item is offline or unpowered, the gates Execute applies.
public ManoeuvreEnvelope Manoeuvre()
{
if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default;
var forward = normalize(Entity.Direction);
var right = forward.Rotate(ItemRotation.Clockwise);
var speed = float2(dot(Entity.Velocity, forward), dot(Entity.Velocity, right));
var rotorSpeed = max(Rpm * _data.RotorDiameter / 100, 1);
var couplingEfficiency = Evaluate(_data.CouplingEfficiency);
var perSecond = Rpm * _data.CouplingLambda * Item.Evaluate(_data.LambdaMultiplier) * _data.RotorMass / Entity.Mass;
float Push(float axisRate, float axisSpeed, float axisRotorSpeed, float direction) =>
axisRate * saturate(1 - axisSpeed / axisRotorSpeed * direction) * couplingEfficiency;
return new ManoeuvreEnvelope(
Push(perSecond.x, speed.x, rotorSpeed.x, 1), Push(perSecond.x, speed.x, rotorSpeed.x, -1),
Push(perSecond.y, speed.y, rotorSpeed.y, -1), Push(perSecond.y, speed.y, rotorSpeed.y, 1),
perSecond.z * ItemManager.GameplaySettings.AetherTorqueMultiplier,
perSecond.z * ItemManager.GameplaySettings.AetherTorqueMultiplier);
}
public override bool Execute(float dt)
{ {
var rotorSpeed = Rpm * _data.RotorDiameter / 100; var rotorSpeed = Rpm * _data.RotorDiameter / 100;

View file

@ -145,7 +145,7 @@ public class Ship : Entity
#region ThrustCalculation #region ThrustCalculation
// What this ship can do at this instant (see ManoeuvreEnvelope): the sum of what each live propulsor reports with // What this ship can do at this instant (see ManoeuvreEnvelope): the sum of what each live propulsor reports with
// its own Execute arithmetic. Derived each update, never saved. The aggregates above are not an envelope (they // 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. // mix units and cancel a lone off-axis strafer), so nothing in it reads them.
public ManoeuvreEnvelope Envelope { get; private set; } public ManoeuvreEnvelope Envelope { get; private set; }
@ -153,7 +153,6 @@ public class Ship : Entity
{ {
var envelope = default(ManoeuvreEnvelope); var envelope = default(ManoeuvreEnvelope);
foreach (var thruster in _allThrusters) envelope += thruster.Manoeuvre(); foreach (var thruster in _allThrusters) envelope += thruster.Manoeuvre();
foreach (var drive in _aetherDrives) envelope += drive.Manoeuvre();
Envelope = envelope; Envelope = envelope;
} }

View file

@ -16,25 +16,27 @@ using int2 = CultMath.int2;
// The evasion term (docs/aetheria-release-map.md, "Evasion is how unpredictably the target can and does change its // The evasion term (docs/aetheria-release-map.md, "Evasion is how unpredictably the target can and does change its
// motion"): how far a target can and does move off the line of fire in the solution window, one factor of every hit // motion"): how far a target can and does move off the line of fire in the solution window, one factor of every hit
// price. The fixture is RunStartTests': the shipped catalog and the authored settings, so the ships fly in the real // price. The fixture is RunStartTests': the shipped catalog and the authored settings, so the ships fly in the real
// Ship.Update with the real thrusters and aether drive. Ship flights step the ships directly (they are alone in // Ship.Update with the real thrusters. Ship flights step the ships directly (they are alone in
// their arena); the one price test that rolls shots steps only FireControl. // their arena); the one price test that rolls shots steps only FireControl.
public sealed partial class RunStartTests public sealed partial class RunStartTests
{ {
private const float EvDt = 1f / 60f; private const float EvDt = 1f / 60f;
// The Duel's LonginusX fit: one Traction aether drive that pushes all four ways, with its guns. // The Duel's guns on a Longinus: the generated Longinus's thrusters (two Large Drive forward, two Talaria on the
private static ScenarioFit EvGemini(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("LonginusX", // flanks) and power, the Duel's cockpit, guns, launchers and sensor, and the Fire Control Array as its targeting gear.
("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Traction", int2(2, 3), ItemRotation.None), ("Core Power", int2(2, 1), ItemRotation.None), private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("Longinus",
("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Core Power", int2(2, 4), ItemRotation.None),
("Large Drive", int2(1, 0), ItemRotation.Reversed), ("Large Drive", int2(3, 0), ItemRotation.Reversed),
("Talaria", int2(2, 14), ItemRotation.CounterClockwise), ("Talaria", int2(3, 14), ItemRotation.Clockwise),
("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None), ("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None),
(gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(1, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(4, 2), ItemRotation.Clockwise), (gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(2, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(3, 2), ItemRotation.Clockwise),
("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 5), ItemRotation.None), ("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 6), ItemRotation.None),
("Fire Control Array", int2(2, 5), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); ("Fire Control Array", int2(1, 10), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None));
// The catalog's three fitted ships, far apart in the fixture arena: the generated Longinus and Djinni at the // The catalog's two generated ships, far apart in the fixture arena, at the fixture seed.
// fixture seed and the omnidirectional LonginusX. private (Zone arena, Ship longinus, Ship djinni) EvFleet()
private (Zone arena, Ship longinus, Ship djinni, Ship gemini) EvFleet()
{ {
Ship longinus = null, djinni = null, gemini = null; Ship longinus = null, djinni = null;
var scenario = new Scripted(false, stage => var scenario = new Scripted(false, stage =>
{ {
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
@ -42,17 +44,15 @@ public sealed partial class RunStartTests
// first, then the Longinus, as the map's probe did (M23). // first, then the Longinus, as the map's probe did (M23).
djinni = stage.Place(stage.Generated("Djinni"), float2(-10000, -30000), facing: float2(0, 1)) as Ship; djinni = stage.Place(stage.Generated("Djinni"), float2(-10000, -30000), facing: float2(0, 1)) as Ship;
longinus = stage.Place(stage.Generated("Longinus"), float2(-30000, -30000), facing: float2(0, 1)) as Ship; longinus = stage.Place(stage.Generated("Longinus"), float2(-30000, -30000), facing: float2(0, 1)) as Ship;
gemini = stage.Place(EvGemini(stage), float2(10000, -30000), facing: float2(0, 1)) as Ship;
}); });
var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed)); var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures)); Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.NotNull(longinus); Assert.NotNull(longinus);
Assert.NotNull(djinni); Assert.NotNull(djinni);
Assert.NotNull(gemini); return (arena, longinus, djinni);
return (arena, longinus, djinni, gemini);
} }
// Idle long enough for stats, power and the aether rotors to reach their idle state, then at rest, facing +y, // Idle long enough for stats and power to reach their idle state, then at rest, facing +y,
// and, after three seconds of coasting, with nothing observed: the track forgets on its own. // and, after three seconds of coasting, with nothing observed: the track forgets on its own.
private static void EvSettle(Ship ship) private static void EvSettle(Ship ship)
{ {
@ -117,8 +117,7 @@ public sealed partial class RunStartTests
foreach (var jink in new[] { EvJink.Lateral, EvJink.Longitudinal, EvJink.Random, EvJink.Weave }) foreach (var jink in new[] { EvJink.Lateral, EvJink.Longitudinal, EvJink.Random, EvJink.Weave })
foreach (var halfPeriod in new[] { .5f, 1f }) foreach (var halfPeriod in new[] { .5f, 1f })
{ {
// A fresh ship each flight where the caller wants one: an aether drive heats and spends its rotors, and a // A fresh ship each flight where the caller wants one: a ship keeps the track its last flight left.
// drive that has run itself offline has no envelope.
var ship = flightShip(); var ship = flightShip();
EvSettle(ship); EvSettle(ship);
EvFly(ship, jink, halfPeriod, 4f); EvFly(ship, jink, halfPeriod, 4f);
@ -132,19 +131,17 @@ public sealed partial class RunStartTests
return (noseOn, broadside); return (noseOn, broadside);
} }
// Ruling evasion-is-unpredictability: the Gemini is evasive because it can floor it in any direction at any time, // Ruling evasion-is-unpredictability: a ship is evasive by what it can do to its motion at any time. The Longinus has
// the Longinus needs to aim itself where it wants to go, and the Djinni is somewhere between. // to aim itself where it wants to go and the Djinni is a lighter ship of small thrusters.
[Fact] [Fact]
public void TheThreeShipsOrder() public void TheShipsOrder()
{ {
var (_, longinus, djinni, _) = EvFleet(); var (_, longinus, djinni) = EvFleet();
var g = EvBest(() => EvFleet().gemini);
var l = EvBest(() => longinus); var l = EvBest(() => longinus);
var d = EvBest(() => djinni); var d = EvBest(() => djinni);
Console.WriteLine($"EVASION window {_items.GameplaySettings.SolutionWindow}: gemini-like nose-on {g.noseOn:F2} broadside {g.broadside:F2}; " + Console.WriteLine($"EVASION window {_items.GameplaySettings.SolutionWindow}: longinus {l.noseOn:F2} {l.broadside:F2}; djinni {d.noseOn:F2} {d.broadside:F2}");
$"longinus {l.noseOn:F2} {l.broadside:F2}; djinni {d.noseOn:F2} {d.broadside:F2}"); Assert.True(l.noseOn > d.noseOn, $"nose-on order: {l.noseOn} > {d.noseOn}");
Assert.True(g.noseOn > l.noseOn && l.noseOn > d.noseOn, $"nose-on order: {g.noseOn} > {l.noseOn} > {d.noseOn}"); Assert.True(l.broadside > d.broadside, $"broadside order: {l.broadside} > {d.broadside}");
Assert.True(g.broadside > l.broadside && l.broadside > d.broadside, $"broadside order: {g.broadside} > {l.broadside} > {d.broadside}");
// Re-measured (M25): the Djinni's thrusters push 1.7 times harder than the figure the map priced it with, so // Re-measured (M25): the Djinni's thrusters push 1.7 times harder than the figure the map priced it with, so
// it evades more than the map's 1.7 m and the Longinus leads it by less than the map's factor of eight. // it evades more than the map's 1.7 m and the Longinus leads it by less than the map's factor of eight.
Assert.True(l.noseOn >= 2 * d.noseOn && l.broadside >= 2 * d.broadside, "the Longinus evades at least twice the Djinni"); Assert.True(l.noseOn >= 2 * d.noseOn && l.broadside >= 2 * d.broadside, "the Longinus evades at least twice the Djinni");
@ -248,7 +245,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void ReachIsWhatTheShipCanDo() public void ReachIsWhatTheShipCanDo()
{ {
var (_, longinus, djinni, _) = EvFleet(); var (_, longinus, djinni) = EvFleet();
const float window = .5f; 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) measured)>();
foreach (var (name, ship) in new[] { ("longinus", longinus), ("djinni", djinni) }) foreach (var (name, ship) in new[] { ("longinus", longinus), ("djinni", djinni) })
@ -278,7 +275,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void TheEnvelopeComesFromTheGear() public void TheEnvelopeComesFromTheGear()
{ {
var (_, longinus, _, gemini) = EvFleet(); var (_, longinus, _) = EvFleet();
EvSettle(longinus); EvSettle(longinus);
// A thruster's Thrust property is refreshed only while it fires: turn each way so the flank thrusters hold what // A thruster's Thrust property is refreshed only while it fires: turn each way so the flank thrusters hold what
// they push with, then read the envelope at rest. // they push with, then read the envelope at rest.
@ -314,13 +311,6 @@ public sealed partial class RunStartTests
longinus.Update(EvDt); longinus.Update(EvDt);
longinus.Update(EvDt); longinus.Update(EvDt);
Assert.InRange(longinus.Envelope.Forward, envelope.Forward * .49f, envelope.Forward * .51f); Assert.InRange(longinus.Envelope.Forward, envelope.Forward * .49f, envelope.Forward * .51f);
// The LonginusX's Traction drive pushes and turns every way.
EvSettle(gemini);
gemini.Update(EvDt);
var drive = gemini.Envelope;
Assert.True(drive.Forward > 1f && drive.Reverse > 1f && drive.Left > 1f && drive.Right > 1f, $"traction: {drive.Forward} {drive.Reverse} {drive.Left} {drive.Right}");
Assert.True(drive.Clockwise > 0f && drive.CounterClockwise > 0f);
} }
// Which thruster pushes which way and turns which way is read from the gear, not assumed symmetric: destroy the // Which thruster pushes which way and turns which way is read from the gear, not assumed symmetric: destroy the
@ -329,7 +319,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void TheEnvelopeFollowsWhichThrustersAreLeft() public void TheEnvelopeFollowsWhichThrustersAreLeft()
{ {
var (_, _, djinni, gemini) = EvFleet(); var (_, _, djinni) = EvFleet();
var settings = _items.GameplaySettings; var settings = _items.GameplaySettings;
EvSettle(djinni); EvSettle(djinni);
// Refresh every thruster's live Thrust by firing it: strafe, burn and turn each way. // Refresh every thruster's live Thrust by firing it: strafe, burn and turn each way.
@ -370,14 +360,6 @@ public sealed partial class RunStartTests
djinni.MovementDirection = float2(-1, 0); djinni.MovementDirection = float2(-1, 0);
djinni.Update(EvDt); djinni.Update(EvDt);
Assert.True(djinni.Acceleration.x < -1f, "it still strafes left"); Assert.True(djinni.Acceleration.x < -1f, "it still strafes left");
// The Traction drive's turn is the rate it turns the hull at when told to turn right.
EvSettle(gemini);
gemini.MovementDirection = float2(0, 0);
gemini.LookDirection = float3(1, 0, 0);
gemini.Update(EvDt);
var turned = Math.Atan2(gemini.Direction.x, gemini.Direction.y);
Assert.InRange((float) turned / EvDt, gemini.Envelope.Clockwise * .9f, gemini.Envelope.Clockwise * 1.1f);
} }
// The reach and the evasion read each side of the envelope for themselves: a forward-only ship that turns clockwise // The reach and the evasion read each side of the envelope for themselves: a forward-only ship that turns clockwise
@ -425,7 +407,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void ManoeuvreIsObserved() public void ManoeuvreIsObserved()
{ {
var (_, longinus, djinni, gemini) = EvFleet(); var (_, longinus, djinni) = EvFleet();
// Coasting: no acceleration, an all-zero track. // Coasting: no acceleration, an all-zero track.
EvSettle(longinus); EvSettle(longinus);
@ -449,15 +431,6 @@ public sealed partial class RunStartTests
djinni.Update(EvDt); djinni.Update(EvDt);
Assert.InRange(djinni.Acceleration.y, djinni.Envelope.Forward * .98f, djinni.Envelope.Forward * 1.02f); Assert.InRange(djinni.Acceleration.y, djinni.Envelope.Forward * .98f, djinni.Envelope.Forward * 1.02f);
// Strafing at full thrust for one update on the Traction drive: the drive's own push over the step.
EvSettle(gemini);
gemini.MovementDirection = float2(1, 0);
gemini.Update(EvDt);
var drive = gemini.GetBehaviors<AetherDrive>().Single();
Assert.True(drive.ThrustDirection.x > 0f);
Assert.InRange(gemini.Acceleration.x, drive.ThrustDirection.x / EvDt * .999f, drive.ThrustDirection.x / EvDt * 1.001f);
Assert.InRange(gemini.Acceleration.x, gemini.Envelope.Right * .95f, gemini.Envelope.Right * 1.05f);
// The same jink observed at two step sizes reads the same unpredictability. // The same jink observed at two step sizes reads the same unpredictability.
var fine = EvSquare(40, .5f, 4f, 1f / 60f, .75f); var fine = EvSquare(40, .5f, 4f, 1f / 60f, .75f);
var coarse = EvSquare(40, .5f, 4f, 1f / 30f, .75f); var coarse = EvSquare(40, .5f, 4f, 1f / 30f, .75f);
@ -471,34 +444,34 @@ public sealed partial class RunStartTests
public void ASteadyBurnAndDitheringAreNotEvasion() public void ASteadyBurnAndDitheringAreNotEvasion()
{ {
var window = _items.GameplaySettings.SolutionWindow; var window = _items.GameplaySettings.SolutionWindow;
// A fresh ship for each phase: an aether drive heats and spends its rotors as it flies. // A fresh ship for each phase: a ship keeps the track and the heat its last flight left.
var gemini = EvFleet().gemini; var djinni = EvFleet().djinni;
float Nose() => FireControl.Evasion(gemini, normalize(gemini.Direction)); float Nose() => FireControl.Evasion(djinni, normalize(djinni.Direction));
EvSettle(gemini); EvSettle(djinni);
EvFly(gemini, EvJink.Lateral, .5f, 4f); EvFly(djinni, EvJink.Lateral, .5f, 4f);
var jinking = Nose(); var jinking = Nose();
Assert.True(jinking > 1f, $"fixture: the jink evades ({jinking})"); Assert.True(jinking > 1f, $"fixture: the jink evades ({jinking})");
gemini = EvFleet().gemini; djinni = EvFleet().djinni;
EvSettle(gemini); EvSettle(djinni);
gemini.MovementDirection = float2(0, 1); djinni.MovementDirection = float2(0, 1);
for (var step = 0; step < (int) Math.Round(3 * window / EvDt); step++) gemini.Update(EvDt); for (var step = 0; step < (int) Math.Round(3 * window / EvDt); step++) djinni.Update(EvDt);
Assert.True(Nose() < .05f * jinking, $"a steady burn: {Nose()} against {jinking}"); Assert.True(Nose() < .05f * jinking, $"a steady burn: {Nose()} against {jinking}");
gemini = EvFleet().gemini; djinni = EvFleet().djinni;
EvSettle(gemini); EvSettle(djinni);
EvFly(gemini, EvJink.Lateral, .05f, 4f); EvFly(djinni, EvJink.Lateral, .05f, 4f);
Assert.True(Nose() < .25f * jinking, $"a dither: {Nose()} against {jinking}"); Assert.True(Nose() < .25f * jinking, $"a dither: {Nose()} against {jinking}");
gemini = EvFleet().gemini; djinni = EvFleet().djinni;
EvSettle(gemini); EvSettle(djinni);
for (var step = 0; step < 240; step++) gemini.Update(EvDt); for (var step = 0; step < 240; step++) djinni.Update(EvDt);
Assert.Equal(0f, Nose()); Assert.Equal(0f, Nose());
} }
// Two shooters on opposite sides of the same target, 200 m away: one with a projectile gun and one with a laser // Two shooters on opposite sides of the same target, 200 m away: one with a projectile gun and one with a laser
// (a beam's authored velocity is 0, a flight time of zero). The target is the omnidirectional LonginusX. // (a beam's authored velocity is 0, a flight time of zero). The target is the generated Djinni, the catalog's one ship that strafes.
private sealed class EvRange private sealed class EvRange
{ {
public Zone Arena; public Zone Arena;
@ -512,9 +485,9 @@ public sealed partial class RunStartTests
var scenario = new Scripted(false, stage => var scenario = new Scripted(false, stage =>
{ {
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
gunner = stage.Place(EvGemini(stage), float2(0, 0), facing: float2(0, 1)) as Ship; gunner = stage.Place(EvLonginus(stage), float2(0, 0), facing: float2(0, 1)) as Ship;
target = stage.Place(EvGemini(stage), float2(0, 200), facing: float2(0, 1)) as Ship; target = stage.Place(stage.Generated("Djinni"), float2(0, 200), facing: float2(0, 1)) as Ship;
lasing = stage.Place(EvGemini(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship; lasing = stage.Place(EvLonginus(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship;
}); });
var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed)); var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures)); Assert.True(failures.Count == 0, string.Join("; ", failures));
@ -640,32 +613,32 @@ public sealed partial class RunStartTests
} }
// Finding coasting-memory: a ship that stops jinking stops evading. The track is never reset by hand here: the // Finding coasting-memory: a ship that stops jinking stops evading. The track is never reset by hand here: the
// Gemini jinks for four seconds, the input is cut, and evasion must read exactly 0 within the window plus the // Djinni jinks for four seconds, the input is cut, and evasion must read exactly 0 within the window plus a tick
// drive's own spin-down (a tick or two: the probe reads 0.783 s against a window of 0.75), and stay 0, with the moments back at exactly zero. // or two, and stay 0, with the moments back at exactly zero.
[Fact] [Fact]
public void AShipThatStopsJinkingStopsEvading() public void AShipThatStopsJinkingStopsEvading()
{ {
var (_, _, _, gemini) = EvFleet(); var (_, _, djinni) = EvFleet();
var window = _items.GameplaySettings.SolutionWindow; var window = _items.GameplaySettings.SolutionWindow;
EvSettle(gemini); EvSettle(djinni);
EvFly(gemini, EvJink.Lateral, 1f, 4f); EvFly(djinni, EvJink.Lateral, 1f, 4f);
var jinking = Math.Max(FireControl.Evasion(gemini, float2(0, 1)), FireControl.Evasion(gemini, EvRight(gemini))); var jinking = Math.Max(FireControl.Evasion(djinni, float2(0, 1)), FireControl.Evasion(djinni, EvRight(djinni)));
Assert.True(jinking > 5f, $"the jink should evade, read {jinking}"); Assert.True(jinking > 1f, $"the jink should evade, read {jinking}");
var zeroAt = -1f; var zeroAt = -1f;
const float bound = 2f; const float bound = 2f;
for (var step = 1; step * EvDt <= bound + 3f; step++) for (var step = 1; step * EvDt <= bound + 3f; step++)
{ {
gemini.Update(EvDt); djinni.Update(EvDt);
var nose = FireControl.Evasion(gemini, float2(0, 1)); var nose = FireControl.Evasion(djinni, float2(0, 1));
var side = FireControl.Evasion(gemini, EvRight(gemini)); var side = FireControl.Evasion(djinni, EvRight(djinni));
if (zeroAt < 0f && nose == 0f && side == 0f) zeroAt = step * EvDt; if (zeroAt < 0f && nose == 0f && side == 0f) zeroAt = step * EvDt;
if (zeroAt >= 0f) Assert.True(nose == 0f && side == 0f, $"evasion came back at {step * EvDt:F2} s: {nose}, {side}"); if (zeroAt >= 0f) Assert.True(nose == 0f && side == 0f, $"evasion came back at {step * EvDt:F2} s: {nose}, {side}");
} }
Assert.True(zeroAt >= 0f && zeroAt <= window + 3 * EvDt, $"evasion reached zero at {zeroAt:F2} s, window {window}"); Assert.True(zeroAt >= 0f && zeroAt <= window + 3 * EvDt, $"evasion reached zero at {zeroAt:F2} s, window {window}");
Assert.Equal(0f, gemini.Manoeuvre.Moments.x); Assert.Equal(0f, djinni.Manoeuvre.Moments.x);
Assert.Equal(0f, gemini.Manoeuvre.Moments.y); Assert.Equal(0f, djinni.Manoeuvre.Moments.y);
Assert.Equal(0f, gemini.Manoeuvre.Moments.z); Assert.Equal(0f, djinni.Manoeuvre.Moments.z);
} }
// Finding limit-clamp-evasion: a velocity limiter's clamp is the ceiling, not a change of vector. A Longinus // Finding limit-clamp-evasion: a velocity limiter's clamp is the ceiling, not a change of vector. A Longinus
@ -673,7 +646,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void ASpeedAboveTheLimitIsNotEvasion() public void ASpeedAboveTheLimitIsNotEvasion()
{ {
var (_, longinus, _, _) = EvFleet(); var (_, longinus, _) = EvFleet();
EvSettle(longinus); EvSettle(longinus);
longinus.Velocity = float2(0, 400); longinus.Velocity = float2(0, 400);
// Read every tick: the track forgets within a window, so only the ticks right after the clamp can see it. // Read every tick: the track forgets within a window, so only the ticks right after the clamp can see it.
@ -693,7 +666,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void TheAggregatesReadLiveThrust() public void TheAggregatesReadLiveThrust()
{ {
var (_, _, djinni, _) = EvFleet(); var (_, _, djinni) = EvFleet();
EvSettle(djinni); EvSettle(djinni);
djinni.Update(EvDt); djinni.Update(EvDt);
Assert.InRange(djinni.ForwardThrust, djinni.Envelope.Forward * djinni.Mass * .99f, djinni.Envelope.Forward * djinni.Mass * 1.01f); Assert.InRange(djinni.ForwardThrust, djinni.Envelope.Forward * djinni.Mass * .99f, djinni.Envelope.Forward * djinni.Mass * 1.01f);
@ -752,7 +725,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void ReachMatchesABruteForceSearchOverTurnStrategies() public void ReachMatchesABruteForceSearchOverTurnStrategies()
{ {
var gemini = new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f); var omni = new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f);
var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f); var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f);
var lopsided = new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f); var lopsided = new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f);
var forwardOnly = new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f); var forwardOnly = new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f);
@ -764,9 +737,9 @@ public sealed partial class RunStartTests
var round = new[] { 135f, -135f, 180f, 100f, -100f }; var round = new[] { 135f, -135f, 180f, 100f, -100f };
var cases = new (ManoeuvreEnvelope envelope, float[] windows, float[] bearings)[] var cases = new (ManoeuvreEnvelope envelope, float[] windows, float[] bearings)[]
{ {
(gemini, new[] { .5f, .75f }, quarter), (longinus, new[] { .5f, .75f }, quarter), (lopsided, new[] { .5f, .75f }, quarter), (omni, new[] { .5f, .75f }, quarter), (longinus, new[] { .5f, .75f }, quarter), (lopsided, new[] { .5f, .75f }, quarter),
(forwardOnly, new[] { .5f, .75f }, quarter), (slow, new[] { .5f, .75f }, quarter), (forwardOnly, new[] { .5f, .75f }, quarter), (slow, new[] { .5f, .75f }, quarter),
(gemini, new[] { .5f, .75f }, round), (slow, new[] { .5f, .75f }, round), (cannotTurn, new[] { .5f, .75f }, round.Concat(quarter).ToArray()), (omni, new[] { .5f, .75f }, round), (slow, new[] { .5f, .75f }, round), (cannotTurn, new[] { .5f, .75f }, round.Concat(quarter).ToArray()),
(lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }), (lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }),
// Ships that can thrust one way only, over windows long enough for every face to finish its turn: the face that // Ships that can thrust one way only, over windows long enough for every face to finish its turn: the face that
// must turn the long way round, past a right angle and past the wrap at 180 degrees, is the best one. // must turn the long way round, past a right angle and past the wrap at 180 degrees, is the best one.
@ -866,7 +839,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void AStepOfNoTimeHasNoAcceleration() public void AStepOfNoTimeHasNoAcceleration()
{ {
var (_, longinus, _, _) = EvFleet(); var (_, longinus, _) = EvFleet();
EvSettle(longinus); EvSettle(longinus);
longinus.MovementDirection = float2(0, 1); longinus.MovementDirection = float2(0, 1);
for (var step = 0; step < 10; step++) longinus.Update(EvDt); for (var step = 0; step < 10; step++) longinus.Update(EvDt);
@ -913,7 +886,7 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void EvasionIsReadFromTheHeadingNotTheVelocity() public void EvasionIsReadFromTheHeadingNotTheVelocity()
{ {
var (_, longinus, _, _) = EvFleet(); var (_, longinus, _) = EvFleet();
var window = _items.GameplaySettings.SolutionWindow; var window = _items.GameplaySettings.SolutionWindow;
EvSettle(longinus); EvSettle(longinus);
longinus.Update(EvDt); longinus.Update(EvDt);
@ -938,62 +911,20 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void TheShipsTrackObservesAtTheSolutionWindow() public void TheShipsTrackObservesAtTheSolutionWindow()
{ {
var gemini = EvFleet().gemini; var djinni = EvFleet().djinni;
var window = _items.GameplaySettings.SolutionWindow; var window = _items.GameplaySettings.SolutionWindow;
EvSettle(gemini); EvSettle(djinni);
var reference = gemini.Manoeuvre; var reference = djinni.Manoeuvre;
for (var step = 0; step < 120; step++) for (var step = 0; step < 120; step++)
{ {
gemini.MovementDirection = float2(step / 30 % 2 == 0 ? 1 : -1, 0); djinni.MovementDirection = float2(step / 30 % 2 == 0 ? 1 : -1, 0);
gemini.Update(EvDt); djinni.Update(EvDt);
reference.Observe(gemini.Acceleration, EvDt, window); reference.Observe(djinni.Acceleration, EvDt, window);
} }
Assert.True(reference.Moments.x > 1f, $"fixture: the jink is observed ({reference.Moments.x})"); Assert.True(reference.Moments.x > 1f, $"fixture: the jink is observed ({reference.Moments.x})");
Assert.InRange(gemini.Manoeuvre.Moments.x, reference.Moments.x * .999f, reference.Moments.x * 1.001f); Assert.InRange(djinni.Manoeuvre.Moments.x, reference.Moments.x * .999f, reference.Moments.x * 1.001f);
Assert.InRange(gemini.Manoeuvre.Moments.z, reference.Moments.z * .999f - .001f, reference.Moments.z * 1.001f + .001f); Assert.InRange(djinni.Manoeuvre.Moments.z, reference.Moments.z * .999f - .001f, reference.Moments.z * 1.001f + .001f);
Assert.InRange(gemini.Manoeuvre.Trend.x, reference.Trend.x - .001f * Math.Abs(reference.Trend.x) - .001f, reference.Trend.x + .001f * Math.Abs(reference.Trend.x) + .001f); Assert.InRange(djinni.Manoeuvre.Trend.x, reference.Trend.x - .001f * Math.Abs(reference.Trend.x) - .001f, reference.Trend.x + .001f * Math.Abs(reference.Trend.x) + .001f);
}
// Finding envelope-inputs-unpinned: a drive that is offline reports nothing, and a working one reports less along the
// way the ship is already outrunning the rotor: half the rotor's speed forward leaves half the push forward and the
// whole push back, and the same across the ship.
[Fact]
public void TheDrivesReportReadsItsStateAndTheShipsSpeed()
{
var gemini = EvFleet().gemini;
EvSettle(gemini);
gemini.Update(EvDt);
var drive = gemini.GetBehaviors<AetherDrive>().Single();
var rest = drive.Manoeuvre();
Assert.True(rest.Forward > 1f && rest.Reverse > 1f && rest.Left > 1f && rest.Right > 1f && rest.Clockwise > .1f, "fixture: a settled drive pushes every way");
var rotor = max(drive.Rpm * drive.DriveData.RotorDiameter / 100, 1);
var forward = normalize(gemini.Direction);
var right = EvRight(gemini);
gemini.Velocity = forward * (.5f * rotor.x);
var ahead = drive.Manoeuvre();
Assert.InRange(ahead.Forward / rest.Forward, .49f, .51f);
Assert.InRange(ahead.Reverse / rest.Reverse, .99f, 1.01f);
gemini.Velocity = -forward * (.5f * rotor.x);
var astern = drive.Manoeuvre();
Assert.InRange(astern.Reverse / rest.Reverse, .49f, .51f);
Assert.InRange(astern.Forward / rest.Forward, .99f, 1.01f);
gemini.Velocity = right * (.5f * rotor.y);
var starboard = drive.Manoeuvre();
Assert.InRange(starboard.Right / rest.Right, .49f, .51f);
Assert.InRange(starboard.Left / rest.Left, .99f, 1.01f);
gemini.Velocity = -right * (.5f * rotor.y);
var port = drive.Manoeuvre();
Assert.InRange(port.Left / rest.Left, .49f, .51f);
Assert.InRange(port.Right / rest.Right, .99f, 1.01f);
gemini.Velocity = float2(0, 0);
drive.Item.EquippableItem.Durability = 0f;
gemini.Update(EvDt);
gemini.Update(EvDt);
Assert.False(drive.Item.Active.Value, "fixture: the drive is offline");
var offline = drive.Manoeuvre();
Assert.Equal(0f, offline.Forward + offline.Reverse + offline.Left + offline.Right + offline.Clockwise + offline.CounterClockwise);
} }
// The pure track forgets: after jinking, a track fed a constant acceleration (none at all, or a steady burn) reads // The pure track forgets: after jinking, a track fed a constant acceleration (none at all, or a steady burn) reads
@ -1034,10 +965,9 @@ public sealed partial class RunStartTests
[Fact] [Fact]
public void EveryAggregateAndTheCompensationReadLiveThrust() public void EveryAggregateAndTheCompensationReadLiveThrust()
{ {
var (_, _, djinni, _) = EvFleet(); var (_, _, djinni) = EvFleet();
EvSettle(djinni); EvSettle(djinni);
djinni.Update(EvDt); djinni.Update(EvDt);
Assert.Empty(djinni.GetBehaviors<AetherDrive>());
var thrusters = djinni.GetBehaviors<Thruster>().Where(t => t.Item.Active.Value).ToList(); var thrusters = djinni.GetBehaviors<Thruster>().Where(t => t.Item.Active.Value).ToList();
float Live(Thruster t) => t.Evaluate(((ThrusterData) t.Data).Thrust); float Live(Thruster t) => t.Evaluate(((ThrusterData) t.Data).Thrust);
float Mount(ItemRotation rotation, Func<Thruster, float> value) => thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).Sum(value); float Mount(ItemRotation rotation, Func<Thruster, float> value) => thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).Sum(value);