With the target's fit written out the jinking price is .383 against .440 coasting, but 200 rolled shots gave 93 hits jinking against 78 coasting: the gap between the prices is smaller than the noise of two 200-shot volleys. 2000 shots put the expected gap near four standard deviations; the assertion is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1073 lines
60 KiB
C#
1073 lines
60 KiB
C#
/* 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;
|
|
using int2 = CultMath.int2;
|
|
|
|
// 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
|
|
// 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. Ship flights step the ships directly (they are alone in
|
|
// their arena); the one price test that rolls shots steps only FireControl.
|
|
public sealed partial class RunStartTests
|
|
{
|
|
private const float EvDt = 1f / 60f;
|
|
|
|
// The Duel's guns on a Longinus: the generated Longinus's thrusters (two Large Drive forward, two Talaria on the
|
|
// flanks) and power, its Targeting Computer, and the Duel's cockpit, guns, launchers and sensor.
|
|
private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-", string second = null) => 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),
|
|
(second ?? 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, 6), ItemRotation.None),
|
|
("Targeting Computer", int2(1, 7), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None));
|
|
|
|
// The catalog's two generated ships, far apart in the fixture arena, at the fixture seed.
|
|
private (Zone arena, Ship longinus, Ship djinni) EvFleet()
|
|
{
|
|
Ship longinus = null, djinni = null;
|
|
var scenario = new Scripted(false, stage =>
|
|
{
|
|
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
|
|
// The draw order is part of the fixture: the generator draws from the run's item stream, so the Djinni is fitted
|
|
// 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;
|
|
longinus = stage.Place(stage.Generated("Longinus"), float2(-30000, -30000), facing: float2(0, 1)) as Ship;
|
|
});
|
|
var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed));
|
|
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
|
Assert.NotNull(longinus);
|
|
Assert.NotNull(djinni);
|
|
return (arena, longinus, djinni);
|
|
}
|
|
|
|
// 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.
|
|
private static void EvSettle(Ship ship)
|
|
{
|
|
ship.MovementDirection = float2(0, 0);
|
|
EvFace(ship, float3(0, 0, 1));
|
|
for (var step = 0; step < 180; step++) ship.Update(EvDt);
|
|
ship.Velocity = float2(0, 0);
|
|
ship.Direction = float2(0, 1);
|
|
}
|
|
|
|
// One of the fixed jinks, from rest facing +y: a square wave across the heading (the jink that crosses a shooter
|
|
// nose-on) or along it (the one that crosses a shooter broadside), a new random direction each half-period, or a
|
|
// turn-and-burn weave of +-60 degrees. Flown in Ship.Update; what the ship does is its own motion.
|
|
private enum EvJink { Lateral, Longitudinal, Random, Weave }
|
|
|
|
private static void EvFly(Ship ship, EvJink jink, float halfPeriod, float seconds)
|
|
{
|
|
var random = new System.Random(7);
|
|
var steps = (int) Math.Round(seconds / EvDt);
|
|
var phaseSteps = Math.Max(1, (int) Math.Round(halfPeriod / EvDt));
|
|
float2 movement = float2(0, 0);
|
|
float3 look = float3(0, 0, 1);
|
|
for (var step = 0; step < steps; step++)
|
|
{
|
|
if (step % phaseSteps == 0)
|
|
{
|
|
var phase = step / phaseSteps;
|
|
var side = phase % 2 == 0 ? 1f : -1f;
|
|
switch (jink)
|
|
{
|
|
case EvJink.Lateral:
|
|
movement = float2(side, 0);
|
|
break;
|
|
case EvJink.Longitudinal:
|
|
movement = float2(0, side);
|
|
break;
|
|
case EvJink.Random:
|
|
var angle = (float) (random.NextDouble() * 2 * Math.PI);
|
|
movement = float2(sin(angle), cos(angle));
|
|
break;
|
|
case EvJink.Weave:
|
|
movement = float2(0, 1);
|
|
look = float3(side * sin(radians(60)), 0, cos(radians(60)));
|
|
break;
|
|
}
|
|
}
|
|
ship.MovementDirection = movement;
|
|
EvFace(ship, look);
|
|
ship.Update(EvDt);
|
|
}
|
|
ship.MovementDirection = float2(0, 0);
|
|
EvFace(ship, float3(0, 0, 1));
|
|
}
|
|
|
|
// Ship.Update reads only Turn: facing a look direction is the aim plus the heading-to-demand law, asked each tick
|
|
// (what Ship.Update's own steering pass did before the helm split).
|
|
private static void EvFace(Ship ship, float3 look)
|
|
{
|
|
ship.Aim = look;
|
|
ship.Turn = Steering.Toward(ship, look.xz);
|
|
}
|
|
|
|
private static float2 EvRight(Ship ship) => normalize(ship.Direction).Rotate(ItemRotation.Clockwise);
|
|
|
|
// The best evasion a ship shows over the fixed jink set, against a shooter nose-on (along its heading) and one
|
|
// broadside, at the authored window.
|
|
private static (float noseOn, float broadside) EvBest(Func<Ship> flightShip)
|
|
{
|
|
float noseOn = 0, broadside = 0;
|
|
foreach (var jink in new[] { EvJink.Lateral, EvJink.Longitudinal, EvJink.Random, EvJink.Weave })
|
|
foreach (var halfPeriod in new[] { .5f, 1f })
|
|
{
|
|
// A fresh ship each flight where the caller wants one: a ship keeps the track its last flight left.
|
|
var ship = flightShip();
|
|
EvSettle(ship);
|
|
EvFly(ship, jink, halfPeriod, 4f);
|
|
var flightNose = FireControl.Evasion(ship, normalize(ship.Direction));
|
|
var flightSide = FireControl.Evasion(ship, EvRight(ship));
|
|
var e = ship.Envelope;
|
|
Console.WriteLine($"EVASION flight {ship.HullData.Name} {jink} {halfPeriod}: nose-on {flightNose:F2} broadside {flightSide:F2} rms x {ship.Manoeuvre.LateralRms(float2(1, 0)):F1} y {ship.Manoeuvre.LateralRms(float2(0, 1)):F1} envelope F{e.Forward:F0} R{e.Reverse:F0} L{e.Left:F0} Rt{e.Right:F0} CW{e.Clockwise:F2} CCW{e.CounterClockwise:F2} speed {length(ship.Velocity):F0}");
|
|
noseOn = max(noseOn, flightNose);
|
|
broadside = max(broadside, flightSide);
|
|
}
|
|
return (noseOn, broadside);
|
|
}
|
|
|
|
// Ruling evasion-is-unpredictability: a ship is evasive by what it can do to its motion at any time. The Longinus has
|
|
// to aim itself where it wants to go and the Djinni is a lighter ship of small thrusters.
|
|
[Fact]
|
|
public void TheShipsOrder()
|
|
{
|
|
var (_, longinus, djinni) = EvFleet();
|
|
var l = EvBest(() => longinus);
|
|
var d = EvBest(() => djinni);
|
|
Console.WriteLine($"EVASION window {_items.GameplaySettings.SolutionWindow}: 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(l.broadside > d.broadside, $"broadside order: {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
|
|
// 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(d.noseOn < 4f && d.broadside < 4f, $"the Djinni is limited by its mass: {d.noseOn}, {d.broadside}");
|
|
}
|
|
|
|
// The speed demon, as pure numbers (ruling speed-demon-counterplay): after 4 s of a lateral square wave at full
|
|
// thrust, half-period 0.5 s, nose-on. The window is passed explicitly, not read from settings.
|
|
private static ManoeuvreTrack EvSquare(float amplitude, float halfPeriod, float seconds, float dt, float window)
|
|
{
|
|
var track = default(ManoeuvreTrack);
|
|
var steps = (int) Math.Round(seconds / dt);
|
|
for (var step = 0; step < steps; step++)
|
|
track.Observe(float2(((int) Math.Floor(step * dt / halfPeriod) % 2 == 0 ? 1f : -1f) * amplitude, 0), dt, window);
|
|
return track;
|
|
}
|
|
|
|
[Fact]
|
|
public void TheSpeedDemonNumbers()
|
|
{
|
|
var demon = new ManoeuvreEnvelope(60, 60, 60, 60, 3, 3);
|
|
var heavy = new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f);
|
|
var noseOn = float2(0, 1);
|
|
|
|
float Evade(ManoeuvreEnvelope envelope, float amplitude, float window) =>
|
|
FireControl.Evasion(envelope, noseOn, EvSquare(amplitude, .5f, 4f, EvDt, window), noseOn, window);
|
|
|
|
// What the code gives (probe at 2910a35d and an independent port of Observe and the box reach, agreeing to 0.01%);
|
|
// the map's 5.07, 9.63 and 0.84 are the model's time means and were never produced by this code. The bound is
|
|
// 0.5%, so a change to the window, either low-pass constant or the half-width reads as a failure.
|
|
var demonShort = Evade(demon, 60, .5f);
|
|
Assert.InRange(demonShort, 5.264f * .995f, 5.264f * 1.005f);
|
|
Assert.InRange(FireControl.PDeviation(demonShort, 10), .474f - .005f, .474f + .005f);
|
|
Assert.InRange(FireControl.PDeviation(demonShort, 50), .895f - .005f, .895f + .005f);
|
|
|
|
var demonLong = Evade(demon, 60, .75f);
|
|
Assert.InRange(demonLong, 10.116f * .995f, 10.116f * 1.005f);
|
|
Assert.Equal(0f, FireControl.PDeviation(demonLong, 10));
|
|
Assert.InRange(FireControl.PDeviation(demonLong, 50), .798f - .005f, .798f + .005f);
|
|
|
|
var heavyShort = Evade(heavy, 10, .5f);
|
|
Assert.InRange(heavyShort, .877f * .995f, .877f * 1.005f);
|
|
Assert.InRange(FireControl.PDeviation(heavyShort, 10), .912f - .005f, .912f + .005f);
|
|
Assert.InRange(FireControl.PDeviation(heavyShort, 50), .983f - .005f, .983f + .005f);
|
|
}
|
|
|
|
// Reach is what the ship can do: the largest displacement from the coasting path in the window, from the
|
|
// envelope alone, against the same ship's best motion in Ship.Update under fifteen control policies (strafe,
|
|
// forward, reverse, corner, turn-and-burn at several angles, each mirrored).
|
|
private static (float noseOn, float broadside) EvMeasuredHalfWidths(Ship ship, float window)
|
|
{
|
|
EvSettle(ship);
|
|
var start = ship.Position;
|
|
float2 Run(float2 move, float2 look)
|
|
{
|
|
ship.Position = start;
|
|
ship.Velocity = float2(0, 0);
|
|
ship.Direction = float2(0, 1);
|
|
ship.MovementDirection = move;
|
|
EvFace(ship, float3(look.x, 0, look.y));
|
|
var steps = (int) Math.Round(window / EvDt);
|
|
for (var step = 0; step < steps; step++) ship.Update(EvDt);
|
|
var displacement = ship.Position.xz - start.xz;
|
|
ship.MovementDirection = float2(0, 0);
|
|
EvFace(ship, float3(0, 0, 1));
|
|
return displacement;
|
|
}
|
|
|
|
var ahead = float2(0, 1);
|
|
var idle = Run(float2(0, 0), ahead);
|
|
var policies = new (float2 move, float2 look)[]
|
|
{
|
|
(float2(1, 0), ahead), (float2(-1, 0), ahead), (float2(0, 1), ahead), (float2(0, -1), ahead),
|
|
(float2(1, 1), ahead), (float2(-1, 1), ahead),
|
|
(float2(0, 1), float2(1, 0)), (float2(0, 1), float2(-1, 0)),
|
|
(float2(1, 1), float2(1, 0)), (float2(-1, 1), float2(-1, 0)),
|
|
(float2(0, 1), normalize(float2(1, 1))), (float2(0, 1), normalize(float2(-1, 1))),
|
|
(float2(0, 1), normalize(float2(1, -1))), (float2(0, 0), float2(1, 0)),
|
|
(float2(0, 1), normalize(float2(.05f, -1)))
|
|
};
|
|
float right = 0, left = 0, forward = 0, back = 0;
|
|
foreach (var (move, look) in policies)
|
|
{
|
|
var d = Run(move, look) - idle;
|
|
right = max(right, d.x);
|
|
left = max(left, -d.x);
|
|
forward = max(forward, d.y);
|
|
back = max(back, -d.y);
|
|
}
|
|
ship.Position = start;
|
|
return ((right + left) / 2, (forward + back) / 2);
|
|
}
|
|
|
|
private static (float noseOn, float broadside) EvReach(Ship ship, 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));
|
|
return (Half(float2(1, 0)), Half(float2(0, 1)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReachIsWhatTheShipCanDo()
|
|
{
|
|
var (_, longinus, djinni) = EvFleet();
|
|
const float window = .5f;
|
|
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) })
|
|
{
|
|
var measured = EvMeasuredHalfWidths(ship, window);
|
|
EvSettle(ship);
|
|
ship.Update(EvDt);
|
|
var reach = EvReach(ship, window);
|
|
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));
|
|
}
|
|
// 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 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);
|
|
}
|
|
}
|
|
|
|
// The envelope is summed from what each live propulsor reports with its own Execute arithmetic.
|
|
[Fact]
|
|
public void TheEnvelopeComesFromTheGear()
|
|
{
|
|
// The Longinus is fitted by name, not generated: what the generator picks follows the item stream, and every role a
|
|
// product authors draws from it (roles-backfill), so a generated hull would stop carrying these drives.
|
|
Ship longinus = null;
|
|
var scenario = new Scripted(false, stage =>
|
|
{
|
|
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
|
|
longinus = stage.Place(EvLonginus(stage), float2(-30000, -30000), facing: float2(0, 1)) as Ship;
|
|
});
|
|
var (_, _, _, failures) = Launch(scenario, Inputs(() => GalaxySeed));
|
|
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
|
Assert.NotNull(longinus);
|
|
EvSettle(longinus);
|
|
// 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.
|
|
foreach (var look in new[] { float3(1, 0, 0), float3(-1, 0, 0) })
|
|
{
|
|
for (var step = 0; step < 20; step++)
|
|
{
|
|
EvFace(longinus, look);
|
|
longinus.Update(EvDt);
|
|
}
|
|
}
|
|
EvSettle(longinus);
|
|
longinus.Update(EvDt);
|
|
var settings = _items.GameplaySettings;
|
|
var thrusters = longinus.GetBehaviors<Thruster>().Where(t => t.Item.Active.Value).ToList();
|
|
var largeDrives = thrusters.Where(t => t.Item.Data.Name == "Large Drive").ToList();
|
|
var flanks = thrusters.Where(t => t.Item.Data.Name == "Talaria").ToList();
|
|
Assert.Equal(2, largeDrives.Count);
|
|
Assert.Equal(2, flanks.Count);
|
|
|
|
var envelope = longinus.Envelope;
|
|
var mass = longinus.Mass;
|
|
// The shipped Large Drive's Thrust is a range its lot's roll moves it through, so each drive pushes within the catalog's
|
|
// range and the envelope forward is what the two push together.
|
|
var catalogThrust = _cache.GetAll<GearData>().Single(gear => gear.Name == "Large Drive").Behaviors.OfType<ThrusterData>().Single().Thrust;
|
|
Assert.All(largeDrives, t => Assert.InRange(t.Thrust, catalogThrust.Min * .99f, catalogThrust.Max * 1.01f));
|
|
var forward = largeDrives.Sum(t => t.Thrust) / mass;
|
|
Assert.InRange(envelope.Forward, forward * .99f, forward * 1.01f);
|
|
Assert.Equal(0f, envelope.Reverse);
|
|
// One Talaria pushes each way, and each has its own lot's thrust: the sides are the two flanks' accelerations.
|
|
var flankAccelerations = flanks.Select(t => t.Thrust / mass).OrderBy(a => a).ToArray();
|
|
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;
|
|
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");
|
|
|
|
// Destroying one Large Drive halves the push forward.
|
|
largeDrives[0].Item.EquippableItem.Durability = 0f;
|
|
longinus.Update(EvDt);
|
|
longinus.Update(EvDt);
|
|
Assert.InRange(longinus.Envelope.Forward, envelope.Forward * .49f, envelope.Forward * .51f);
|
|
}
|
|
|
|
// Which thruster pushes which way and turns which way is read from the gear, not assumed symmetric: destroy the
|
|
// Djinni's right-pushing thrusters and the envelope loses its right push and the turn they gave one way, which
|
|
// the ship's own flight confirms.
|
|
[Fact]
|
|
public void TheEnvelopeFollowsWhichThrustersAreLeft()
|
|
{
|
|
var (_, _, djinni) = EvFleet();
|
|
var settings = _items.GameplaySettings;
|
|
EvSettle(djinni);
|
|
// Refresh every thruster's live Thrust by firing it: strafe, burn and turn each way.
|
|
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)), (float2(0, 0), float3(1, 0, 0)), (float2(0, 0), float3(-1, 0, 0)) })
|
|
{
|
|
djinni.MovementDirection = move;
|
|
for (var step = 0; step < 20; step++)
|
|
{
|
|
EvFace(djinni, look);
|
|
djinni.Update(EvDt);
|
|
}
|
|
}
|
|
EvSettle(djinni);
|
|
djinni.Update(EvDt);
|
|
var whole = djinni.Envelope;
|
|
|
|
// Full reverse for one update accelerates at the reverse push.
|
|
EvSettle(djinni);
|
|
djinni.MovementDirection = float2(0, -1);
|
|
djinni.Update(EvDt);
|
|
Assert.InRange(-djinni.Acceleration.y, whole.Reverse * .98f, whole.Reverse * 1.02f);
|
|
|
|
foreach (var thruster in djinni.GetBehaviors<Thruster>().Where(t => t.Item.EquippableItem.Rotation == ItemRotation.CounterClockwise))
|
|
thruster.Item.EquippableItem.Durability = 0f;
|
|
EvSettle(djinni);
|
|
djinni.Update(EvDt);
|
|
djinni.Update(EvDt);
|
|
var maimed = djinni.Envelope;
|
|
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;
|
|
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);
|
|
djinni.MovementDirection = float2(1, 0);
|
|
djinni.Update(EvDt);
|
|
Assert.InRange(djinni.Acceleration.x, -.5f, .5f);
|
|
djinni.MovementDirection = float2(-1, 0);
|
|
djinni.Update(EvDt);
|
|
Assert.True(djinni.Acceleration.x < -1f, "it still strafes left");
|
|
}
|
|
|
|
// 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
|
|
// make. A diagonal jink reads as unpredictable along the diagonal and not across it.
|
|
[Fact]
|
|
public void ReachAndEvasionReadEachSideOfTheEnvelope()
|
|
{
|
|
var forwardOnly = new ManoeuvreEnvelope(50, 0, 0, 0, 4, .5f);
|
|
var ahead = float2(0, 1);
|
|
var toRight = FireControl.Reach(forwardOnly, ahead, float2(1, 0), .75f);
|
|
var toLeft = FireControl.Reach(forwardOnly, ahead, float2(-1, 0), .75f);
|
|
Assert.True(toRight > 2 * toLeft && toLeft > 0f, $"right {toRight}, left {toLeft}");
|
|
|
|
var wild = default(ManoeuvreTrack);
|
|
for (var step = 0; step < 240; step++) wild.Observe(float2(step / 30 % 2 == 0 ? 500 : -500, 0), EvDt, .75f);
|
|
var evasion = FireControl.Evasion(forwardOnly, ahead, wild, float2(0, 1), .75f);
|
|
Assert.InRange(evasion, .5f * (toRight + toLeft) * .999f, .5f * (toRight + toLeft) * 1.001f);
|
|
|
|
var diagonal = default(ManoeuvreTrack);
|
|
for (var step = 0; step < 240; step++)
|
|
diagonal.Observe(float2(1, 1) * (step / 30 % 2 == 0 ? 40 : -40), EvDt, .75f);
|
|
var along = diagonal.LateralRms(normalize(float2(1, 1)));
|
|
var across = diagonal.LateralRms(normalize(float2(1, -1)));
|
|
Assert.InRange(along, diagonal.LateralRms(float2(1, 0)) * 1.414f * .97f, diagonal.LateralRms(float2(1, 0)) * 1.414f * 1.03f);
|
|
Assert.True(across < .05f * along, $"along {along}, across {across}");
|
|
}
|
|
|
|
// A station or any other non-Ship has no envelope; whatever it does, it does not evade.
|
|
[Fact]
|
|
public void NonShipsDoNotEvade()
|
|
{
|
|
var zone = Arena(true);
|
|
var station = zone.Entities.First(entity => !(entity is Ship));
|
|
for (var step = 0; step < 240; step++)
|
|
station.Manoeuvre.Observe(float2(step / 30 % 2 == 0 ? 60 : -60, 0), EvDt, _items.GameplaySettings.SolutionWindow);
|
|
Assert.True(station.Manoeuvre.LateralRms(float2(1, 0)) > 1f, "fixture: the station's own track reads a jink");
|
|
Assert.Equal(0f, FireControl.Evasion(station, float2(0, 1)));
|
|
Assert.Equal(0f, FireControl.Evasion(station, float2(1, 0)));
|
|
}
|
|
|
|
// Entity.Update is the one writer of Acceleration and Manoeuvre: the velocity change across the base update, so
|
|
// thrust and not drag or gravity.
|
|
[Fact]
|
|
public void ManoeuvreIsObserved()
|
|
{
|
|
var (_, longinus, djinni) = EvFleet();
|
|
|
|
// Coasting: no acceleration, an all-zero track.
|
|
EvSettle(longinus);
|
|
longinus.Velocity = float2(40, 0);
|
|
for (var step = 0; step < 60; step++) longinus.Update(EvDt);
|
|
Assert.Equal(0f, lengthsq(longinus.Acceleration));
|
|
Assert.Equal(0f, lengthsq(longinus.Manoeuvre.Trend) + lengthsq(longinus.Manoeuvre.Innovation) + lengthsq(longinus.Manoeuvre.Moments));
|
|
|
|
// A full burn for one update: the acceleration is the thrust over the mass, along the heading. (The Large
|
|
// Drives' opposed torques turn the hull a little between their two pushes, hence the sideways slack.)
|
|
EvSettle(longinus);
|
|
longinus.MovementDirection = float2(0, 1);
|
|
longinus.Update(EvDt);
|
|
Assert.InRange(longinus.Acceleration.y, longinus.Envelope.Forward * .995f, longinus.Envelope.Forward * 1.005f);
|
|
Assert.InRange(abs(longinus.Acceleration.x), 0f, .03f * longinus.Envelope.Forward);
|
|
|
|
// The Djinni's thrusters push harder than their construction value (M25): measured acceleration is what its
|
|
// envelope says.
|
|
EvSettle(djinni);
|
|
djinni.MovementDirection = float2(0, 1);
|
|
djinni.Update(EvDt);
|
|
Assert.InRange(djinni.Acceleration.y, djinni.Envelope.Forward * .98f, djinni.Envelope.Forward * 1.02f);
|
|
|
|
// The same jink observed at two step sizes reads the same unpredictability.
|
|
var fine = EvSquare(40, .5f, 4f, 1f / 60f, .75f);
|
|
var coarse = EvSquare(40, .5f, 4f, 1f / 30f, .75f);
|
|
var n = float2(1, 0);
|
|
Assert.InRange(coarse.LateralRms(n), fine.LateralRms(n) * .9f, fine.LateralRms(n) * 1.1f);
|
|
}
|
|
|
|
// A ship holding a steady burn, or dithering far faster than the window, does not evade; a jink at the window's
|
|
// own scale does; a coasting ship evades nothing.
|
|
[Fact]
|
|
public void ASteadyBurnAndDitheringAreNotEvasion()
|
|
{
|
|
var window = _items.GameplaySettings.SolutionWindow;
|
|
// A fresh ship for each phase: a ship keeps the track and the heat its last flight left.
|
|
var djinni = EvFleet().djinni;
|
|
float Nose() => FireControl.Evasion(djinni, normalize(djinni.Direction));
|
|
|
|
EvSettle(djinni);
|
|
EvFly(djinni, EvJink.Lateral, .5f, 4f);
|
|
var jinking = Nose();
|
|
Assert.True(jinking > 1f, $"fixture: the jink evades ({jinking})");
|
|
|
|
djinni = EvFleet().djinni;
|
|
EvSettle(djinni);
|
|
djinni.MovementDirection = float2(0, 1);
|
|
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}");
|
|
|
|
djinni = EvFleet().djinni;
|
|
EvSettle(djinni);
|
|
EvFly(djinni, EvJink.Lateral, .05f, 4f);
|
|
Assert.True(Nose() < .25f * jinking, $"a dither: {Nose()} against {jinking}");
|
|
|
|
djinni = EvFleet().djinni;
|
|
EvSettle(djinni);
|
|
for (var step = 0; step < 240; step++) djinni.Update(EvDt);
|
|
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
|
|
// (a beam's authored velocity is 0, a flight time of zero). The target is the fitted Djinni, the catalog's one ship that strafes.
|
|
private sealed class EvRange
|
|
{
|
|
public Zone Arena;
|
|
public Ship Gunner, Lasing, Target;
|
|
public Weapon Gun, Laser, GunnerLaser;
|
|
}
|
|
|
|
// The target's fit, written out: the Djinni's own hardpoints filled with the Talaria it is generated with, its
|
|
// reactor, cockpit and radiators. What the evasion tests price is this ship's strafing, not whatever the generator
|
|
// rolls for it from the galaxy.
|
|
private static ScenarioFit EvDjinni(ScenarioStage stage) => stage.Fit("Djinni",
|
|
("Cockpit 2x2", int2(6, 3), ItemRotation.None), ("Core Power", int2(6, 11), ItemRotation.None),
|
|
("Talaria", int2(6, 0), ItemRotation.Reversed), ("Talaria", int2(5, 1), ItemRotation.Reversed), ("Talaria", int2(7, 1), ItemRotation.Reversed),
|
|
("Talaria", int2(4, 4), ItemRotation.CounterClockwise), ("Talaria", int2(4, 11), ItemRotation.CounterClockwise),
|
|
("Talaria", int2(9, 4), ItemRotation.Clockwise), ("Talaria", int2(9, 11), ItemRotation.Clockwise),
|
|
("Talaria", int2(6, 14), ItemRotation.None),
|
|
("Iapyx", int2(1, 4), ItemRotation.Reversed), ("Iapyx", int2(3, 1), ItemRotation.Reversed),
|
|
("Iapyx", int2(9, 1), ItemRotation.Reversed), ("Iapyx", int2(11, 4), ItemRotation.Reversed));
|
|
|
|
private EvRange EvRangeLaunch()
|
|
{
|
|
Ship gunner = null, lasing = null, target = null;
|
|
var scenario = new Scripted(false, stage =>
|
|
{
|
|
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
|
|
gunner = stage.Place(EvLonginus(stage, "FastBlast+-", "ColdFire"), float2(0, 0), facing: float2(0, 1)) as Ship;
|
|
target = stage.Place(EvDjinni(stage), float2(0, 200), 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));
|
|
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
|
foreach (var shooter in new[] { gunner, lasing })
|
|
{
|
|
shooter.SetTarget(target);
|
|
shooter.EntityInfoGathered[target] = 1f;
|
|
// These tests price evasion, not the mount: the catalog's authored rates would price a 150 m/s crosser at 200 m
|
|
// at nothing, so the guns follow anything here. EvasionTrackingTests sets the rates it means (EvMountRange).
|
|
foreach (var weapon in shooter.GetBehaviors<Weapon>())
|
|
weapon.WeaponData.Tracking = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity };
|
|
}
|
|
arena.Update(1f);
|
|
Assert.True(gunner.VisibleEntities.Contains(target) && lasing.VisibleEntities.Contains(target), "fixture: the shooters see the target");
|
|
var gun = gunner.GetBehaviors<Weapon>().First(weapon => weapon is AutoWeapon && weapon.Velocity > 100f);
|
|
var laser = lasing.GetBehaviors<Weapon>().First(weapon => weapon is AutoWeapon && weapon.Velocity <= .01f);
|
|
var gunnerLaser = gunner.GetBehaviors<Weapon>().First(weapon => weapon is AutoWeapon && weapon.Velocity <= .01f);
|
|
return new EvRange { Arena = arena, Gunner = gunner, Lasing = lasing, Target = target, Gun = gun, Laser = laser, GunnerLaser = gunnerLaser };
|
|
}
|
|
|
|
// The target crossing at 150 m/s (or at the velocity given), either coasting or after two seconds of jinking, at the
|
|
// same velocity, heading and place.
|
|
private static void EvCross(EvRange range, bool jinking, float2? velocity = null)
|
|
{
|
|
var target = range.Target;
|
|
EvSettle(target);
|
|
if (jinking) EvFly(target, EvJink.Lateral, .5f, 2f);
|
|
target.Velocity = velocity ?? float2(150, 0);
|
|
target.Direction = float2(0, 1);
|
|
target.Position = float3(0, target.Position.y, 200);
|
|
}
|
|
|
|
// Fires a volley and returns the hits it committed and the mean price those shots were committed at.
|
|
private static (int hits, float price) EvVolley(EvRange range, Weapon weapon, Ship shooter, int shots)
|
|
{
|
|
var committed = 0;
|
|
var hits = 0;
|
|
using var subscription = range.Arena.ShotCommitted.Subscribe(outcome => { committed++; if (outcome.Hit) hits++; });
|
|
var ids = new List<int>();
|
|
for (var i = 0; i < shots; i++)
|
|
{
|
|
var id = FireControl.Fire(weapon, weapon.Item, shooter);
|
|
Assert.True(id > 0, "fixture: the weapon fires");
|
|
ids.Add(id);
|
|
}
|
|
var price = ids
|
|
.Select(id => range.Arena.PendingShots.Single(shot => shot.ShotId == id))
|
|
.Average(shot => FireControl.CommitProbability(shot, range.Arena.Time, out _));
|
|
FireControl.Step(range.Arena, .01f);
|
|
Assert.Equal(shots, committed);
|
|
return (hits, price);
|
|
}
|
|
|
|
// The two volleys' hit counts are rolls, so their gap is the price gap plus noise. At the price gap this fixture
|
|
// has (about .06 on .44) 200 shots flip the comparison one seed in eight; this many keeps it near four standard deviations.
|
|
private const int EvVolleySize = 2000;
|
|
|
|
// The evasion term is in the live price, the HUD's forecast and the roll: a ship crossing at 150 m/s that has been
|
|
// jinking is harder to hit than the same ship coasting, for a projectile gun.
|
|
[Fact]
|
|
public void ACoastingAgileShipIsEasierToHitThanTheSameShipJinking()
|
|
{
|
|
var range = EvRangeLaunch();
|
|
var gun = range.Gun;
|
|
var shooter = range.Gunner;
|
|
var target = range.Target;
|
|
|
|
EvCross(range, jinking: false);
|
|
var coastingDiagnostic = FireControl.Inspect(gun, shooter, target);
|
|
var coastingPrice = FireControl.HitProbability(gun, shooter, target);
|
|
var coasting = EvVolley(range, gun, shooter, EvVolleySize);
|
|
Assert.Equal(0f, coastingDiagnostic.Evasion);
|
|
Assert.Equal(1f, coastingDiagnostic.PEvasion);
|
|
Assert.True(coastingPrice > .2f, $"fixture: a coasting target is hittable ({coastingPrice})");
|
|
|
|
EvCross(range, jinking: true);
|
|
var jinkingDiagnostic = FireControl.Inspect(gun, shooter, target);
|
|
var jinkingPrice = FireControl.HitProbability(gun, shooter, target);
|
|
var jinking = EvVolley(range, gun, shooter, EvVolleySize);
|
|
Console.WriteLine($"EVASION price: coasting {coastingPrice:F3} hits {coasting.hits}/{EvVolleySize} committed {coasting.price:F3}; " +
|
|
$"jinking {jinkingPrice:F3} (evasion {jinkingDiagnostic.Evasion:F2} m, tracking {jinkingDiagnostic.Tracking:F1}, PEvasion {jinkingDiagnostic.PEvasion:F3}) " +
|
|
$"hits {jinking.hits}/{EvVolleySize} committed {jinking.price:F3}");
|
|
|
|
Assert.True(jinkingDiagnostic.Evasion > 0f);
|
|
Assert.True(jinkingDiagnostic.PEvasion < 1f);
|
|
Assert.True(jinkingPrice < coastingPrice, "the live price falls");
|
|
Assert.True(jinking.price < coasting.price, "the committed price falls");
|
|
Assert.True(jinking.hits < coasting.hits, $"the committed hit rate falls: {jinking.hits} against {coasting.hits}");
|
|
|
|
// The HUD's number stays the price: the diagnostic's base is the live price, and at fire time, with no
|
|
// deviation yet realized, the live price is the commit price.
|
|
Assert.Equal(jinkingPrice, jinkingDiagnostic.PBase, 6);
|
|
var id = FireControl.Fire(gun, gun.Item, shooter);
|
|
var shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
|
|
Assert.InRange(FireControl.CommitProbability(shot, range.Arena.Time, out _), jinkingPrice * .999f, jinkingPrice * 1.001f);
|
|
}
|
|
|
|
// Ruling evasion-not-only-flight-time: a laser has no flight time to dodge, and a jinking target still evades it.
|
|
// The whole difference is the evasion term at the shooter's Tracking.
|
|
[Fact]
|
|
public void AnInstantWeaponFeelsTheJink()
|
|
{
|
|
var range = EvRangeLaunch();
|
|
var laser = range.Laser;
|
|
var shooter = range.Lasing;
|
|
var target = range.Target;
|
|
Assert.True(laser.Velocity <= .01f, "fixture: the beam has no flight time");
|
|
|
|
(float hit, float commit, PendingShot shot) Price(bool jinking)
|
|
{
|
|
EvCross(range, jinking);
|
|
var hit = FireControl.HitProbability(laser, shooter, target);
|
|
var id = FireControl.Fire(laser, laser.Item, shooter);
|
|
var shot = range.Arena.PendingShots.Single(s => s.ShotId == id);
|
|
return (hit, FireControl.CommitProbability(shot, range.Arena.Time, out _), shot);
|
|
}
|
|
|
|
var coasting = Price(false);
|
|
var jinking = Price(true);
|
|
Assert.True(coasting.commit > .2f, $"fixture: a coasting target is hittable ({coasting.commit})");
|
|
Assert.True(jinking.hit < coasting.hit, $"live: {jinking.hit} against {coasting.hit}");
|
|
Assert.True(jinking.commit < coasting.commit, $"commit: {jinking.commit} against {coasting.commit}");
|
|
|
|
var evasion = FireControl.Evasion(target, jinking.shot.TravelDirection);
|
|
Assert.True(evasion > 0f);
|
|
var pDeviation = FireControl.DeviationProbability(jinking.shot, range.Arena.Time, out var deviation);
|
|
Assert.Equal(evasion, deviation, 4); // nothing realized: a beam commits where it fires
|
|
var expected = FireControl.PDeviation(evasion, jinking.shot.Tracking);
|
|
Assert.Equal(expected, pDeviation, 5);
|
|
Assert.InRange(jinking.commit / coasting.commit, expected * .999f, expected * 1.001f);
|
|
Assert.InRange(jinking.hit / coasting.hit, expected * .999f, expected * 1.001f);
|
|
}
|
|
|
|
// Finding coasting-memory: a ship that stops jinking stops evading. The track is never reset by hand here: the
|
|
// Djinni jinks for four seconds, the input is cut, and evasion must read exactly 0 within the window plus a tick
|
|
// or two, and stay 0, with the moments back at exactly zero.
|
|
[Fact]
|
|
public void AShipThatStopsJinkingStopsEvading()
|
|
{
|
|
var (_, _, djinni) = EvFleet();
|
|
var window = _items.GameplaySettings.SolutionWindow;
|
|
EvSettle(djinni);
|
|
EvFly(djinni, EvJink.Lateral, .5f, 4f);
|
|
var jinking = Math.Max(FireControl.Evasion(djinni, float2(0, 1)), FireControl.Evasion(djinni, EvRight(djinni)));
|
|
Assert.True(jinking > 1f, $"the jink should evade, read {jinking}");
|
|
|
|
var zeroAt = -1f;
|
|
const float bound = 2f;
|
|
for (var step = 1; step * EvDt <= bound + 3f; step++)
|
|
{
|
|
djinni.Update(EvDt);
|
|
var nose = FireControl.Evasion(djinni, float2(0, 1));
|
|
var side = FireControl.Evasion(djinni, EvRight(djinni));
|
|
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}");
|
|
}
|
|
Assert.True(zeroAt >= 0f && zeroAt <= window + 3 * EvDt, $"evasion reached zero at {zeroAt:F2} s, window {window}");
|
|
Assert.Equal(0f, djinni.Manoeuvre.Moments.x);
|
|
Assert.Equal(0f, djinni.Manoeuvre.Moments.y);
|
|
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
|
|
// assigned four times its top speed and given no input evades nothing.
|
|
[Fact]
|
|
public void ASpeedAboveTheLimitIsNotEvasion()
|
|
{
|
|
var (_, longinus, _) = EvFleet();
|
|
EvSettle(longinus);
|
|
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.
|
|
for (var step = 0; step < 2 * 60; step++)
|
|
{
|
|
longinus.Update(EvDt);
|
|
Assert.Equal(0f, FireControl.Evasion(longinus, float2(0, 1)));
|
|
Assert.Equal(0f, FireControl.Evasion(longinus, EvRight(longinus)));
|
|
Assert.Equal(0f, longinus.Manoeuvre.Moments.x);
|
|
Assert.Equal(0f, longinus.Manoeuvre.Moments.z);
|
|
}
|
|
}
|
|
|
|
// 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,.
|
|
[Fact]
|
|
public void TheAggregatesReadLiveThrust()
|
|
{
|
|
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<Thruster>().ToList();
|
|
Assert.NotEmpty(thrusters);
|
|
foreach (var thruster in thrusters)
|
|
{
|
|
var stat = ((ThrusterData) thruster.Data).Thrust;
|
|
Assert.Equal(thruster.Evaluate(stat), thruster.Thrust);
|
|
Assert.True(thruster.Thrust > stat.Min * 1.1f, $"idle thrust {thruster.Thrust} against the stat minimum {stat.Min}");
|
|
}
|
|
}
|
|
|
|
// ---- Fix batch B: the inputs and the geometry the first two passes left unpinned.
|
|
|
|
// The brute-force search M26 matched Reach against (scratch script, 2026-10-06; docs/aetheria-release-map.md): turn
|
|
// monotonically at full rate by some whole number of degrees, then hold, thrusting with the box support of the
|
|
// wanted direction throughout; 400 midpoint steps; the best of the 361 turns. `beta` is the wanted direction's angle
|
|
// clockwise from the heading.
|
|
private static double EvBruteReach(ManoeuvreEnvelope e, double beta, double window)
|
|
{
|
|
double Support(double g)
|
|
{
|
|
var c = Math.Cos(g);
|
|
var s = Math.Sin(g);
|
|
return (c > 0 ? e.Forward * c : -e.Reverse * c) + (s > 0 ? e.Right * s : -e.Left * s);
|
|
}
|
|
|
|
const int steps = 400;
|
|
var dt = window / steps;
|
|
var best = 0.0;
|
|
for (var degrees = -180; degrees <= 180; degrees++)
|
|
{
|
|
var delta = degrees * Math.PI / 180;
|
|
var rate = delta > 0 ? e.Clockwise : e.CounterClockwise;
|
|
var turnTime = rate > 0 ? Math.Abs(delta) / rate : delta == 0 ? 0 : 1e9;
|
|
var total = 0.0;
|
|
for (var k = 0; k < steps; k++)
|
|
{
|
|
var t = (k + .5) * dt;
|
|
var angle = turnTime > 0 ? delta * Math.Min(1, t / turnTime) : delta;
|
|
total += (window - t) * Math.Max(0, Support(beta - angle)) * dt;
|
|
}
|
|
best = Math.Max(best, total);
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// Finding reach-geometry-unpinned: Reach is the largest displacement a turn-then-thrust search finds, to the
|
|
// fraction of a percent M26 measured at 0, 45 and 90 degrees, for envelopes whose rates and accelerations all
|
|
// differ from one another and from 1 (a rate or an acceleration of 1 hides a divide that should be a multiply),
|
|
// two windows, and a heading that is not along an axis. The same holds all the way round for the envelopes below
|
|
// (the corner faces and the wrap past 180 degrees), and for a ship that cannot turn at all, where the answer is
|
|
// the box support with no turn. The case list was fixed from a Python port of the search before any C# read it.
|
|
[Fact]
|
|
public void ReachMatchesABruteForceSearchOverTurnStrategies()
|
|
{
|
|
var omni = new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f);
|
|
var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f);
|
|
var lopsided = new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f);
|
|
var forwardOnly = new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f);
|
|
var slow = new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f);
|
|
var cannotTurn = new ManoeuvreEnvelope(20, 10, 5, 15, 0, 0);
|
|
var leftOnly = new ManoeuvreEnvelope(0, 0, 40, 0, 3, 1.5f);
|
|
var rightOnly = new ManoeuvreEnvelope(0, 0, 0, 40, 1.5f, 3);
|
|
var quarter = new[] { 0f, 45f, 90f, -45f, -90f };
|
|
var round = new[] { 135f, -135f, 180f, 100f, -100f };
|
|
var cases = new (ManoeuvreEnvelope envelope, float[] windows, float[] bearings)[]
|
|
{
|
|
(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),
|
|
(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 }),
|
|
// 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.
|
|
(forwardOnly, new[] { 1f, 1.5f, 2f }, new[] { 160f, 135f, -135f, 170f, 120f, -120f }),
|
|
(leftOnly, new[] { 1f, 1.5f }, new[] { 160f, 135f, -135f, 170f, -170f, -120f, -100f }),
|
|
(rightOnly, new[] { 1f, 1.5f }, new[] { 160f, 135f, -135f, 170f, -170f, 120f, 100f })
|
|
};
|
|
var checkedCases = 0;
|
|
foreach (var heading in new[] { float2(0, 1), normalize(float2(2, -1)) })
|
|
{
|
|
var forward = normalize(heading);
|
|
var right = forward.Rotate(ItemRotation.Clockwise);
|
|
foreach (var (envelope, windows, bearings) in cases)
|
|
foreach (var window in windows)
|
|
foreach (var bearing in bearings)
|
|
{
|
|
var wanted = forward * cos(radians(bearing)) + right * sin(radians(bearing));
|
|
var reach = FireControl.Reach(envelope, heading, wanted, window);
|
|
var brute = EvBruteReach(envelope, bearing * Math.PI / 180, window);
|
|
Assert.True(brute > .5, $"fixture: the search finds a reach ({brute})");
|
|
Assert.True(Math.Abs(reach - brute) <= .005 * brute,
|
|
$"heading {heading} window {window} bearing {bearing} F{envelope.Forward} R{envelope.Reverse} L{envelope.Left} Rt{envelope.Right} CW{envelope.Clockwise} CCW{envelope.CounterClockwise}: reach {reach:F4}, search {brute:F4}");
|
|
checkedCases++;
|
|
}
|
|
}
|
|
Assert.Equal(2 * cases.Sum(c => c.windows.Length * c.bearings.Length), checkedCases);
|
|
}
|
|
|
|
// Where a ship must turn more than a right angle before a face points the way it wants to go, the eight-face form
|
|
// undershoots the search (the Longinus, which cannot thrust backwards: 3% at 135 degrees in half a second, 16% in
|
|
// three quarters). The values are the faces' own, from the Python port of the form (evasion_model.reach_faces), and
|
|
// pin the time a face spends turning before it may thrust.
|
|
[Fact]
|
|
public void ReachPastARightAngleOfTurnIsTheFaceFormsOwn()
|
|
{
|
|
var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f);
|
|
var heading = normalize(float2(2, -1));
|
|
var right = heading.Rotate(ItemRotation.Clockwise);
|
|
foreach (var (window, bearing, expected) in new[] { (.5f, 135f, 2.5511f), (.75f, 135f, 5.9491f), (.5f, 100f, 3.9492f), (.75f, -100f, 10.4735f), (.5f, -135f, 2.5511f) })
|
|
{
|
|
var reach = FireControl.Reach(longinus, heading, heading * cos(radians(bearing)) + right * sin(radians(bearing)), window);
|
|
Assert.InRange(reach, expected * .995f, expected * 1.005f);
|
|
}
|
|
}
|
|
|
|
// A line of sight or a heading of no length has no across and no reach: the term falls back to a sight along +y for
|
|
// the first and reads nothing for the second.
|
|
[Fact]
|
|
public void ADegenerateLineOfSightOrHeadingIsHandled()
|
|
{
|
|
const float window = .75f;
|
|
var roomy = new ManoeuvreEnvelope(200, 200, 200, 200, 20, 20);
|
|
var track = EvAlong(float2(1, 0), window);
|
|
var fallback = FireControl.Evasion(roomy, float2(0, 1), track, float2(0, 0), window);
|
|
var along = FireControl.Evasion(roomy, float2(0, 1), track, float2(0, 1), window);
|
|
Assert.True(along > 3f, $"fixture: the jink spreads across +y ({along})");
|
|
Assert.InRange(fallback, along * .999f, along * 1.001f);
|
|
Assert.Equal(0f, FireControl.Reach(roomy, float2(0, 0), float2(1, 0), window));
|
|
Assert.Equal(0f, FireControl.Reach(roomy, float2(0, 1), float2(0, 0), window));
|
|
}
|
|
|
|
// The track's own guards: a step of no time or a window of none leaves the track as it was, a change of exactly the
|
|
// threshold is not a change, and the track forgets on the very tick the quiet reaches the window (a quarter-second
|
|
// step and a one second window are exact in floats).
|
|
[Fact]
|
|
public void ATrackCountsQuietExactlyAndIgnoresDegenerateSteps()
|
|
{
|
|
var jinked = EvAlong(float2(1, 0), .75f);
|
|
var before = jinked;
|
|
jinked.Observe(float2(30, -4), 0f, .75f);
|
|
jinked.Observe(float2(30, -4), EvDt, 0f);
|
|
Assert.Equal(before.Last, jinked.Last);
|
|
Assert.Equal(before.Quiet, jinked.Quiet);
|
|
Assert.Equal(before.Moments, jinked.Moments);
|
|
|
|
var held = default(ManoeuvreTrack);
|
|
held.Observe(float2(ManoeuvreTrack.ChangeThreshold, 0), EvDt, .75f);
|
|
Assert.Equal(EvDt, held.Quiet);
|
|
held.Observe(float2(ManoeuvreTrack.ChangeThreshold, ManoeuvreTrack.ChangeThreshold), EvDt, .75f);
|
|
Assert.Equal(2 * EvDt, held.Quiet);
|
|
held.Observe(float2(.5f, ManoeuvreTrack.ChangeThreshold), EvDt, .75f);
|
|
Assert.Equal(0f, held.Quiet);
|
|
|
|
var coarse = default(ManoeuvreTrack);
|
|
for (var tick = 0; tick < 16; tick++) coarse.Observe(float2(tick % 2 == 0 ? 40f : -40f, 0), .25f, 1f);
|
|
Assert.True(coarse.Moments.x > 1f, "fixture: the jink is observed");
|
|
var first = -1;
|
|
for (var tick = 0; tick < 8; tick++)
|
|
{
|
|
coarse.Observe(float2(0, 0), .25f, 1f);
|
|
if (first < 0 && coarse.Moments.x == 0f) first = tick;
|
|
}
|
|
Assert.Equal(4, first);
|
|
}
|
|
|
|
// The acceleration of a step of no time is none, not a division by zero.
|
|
[Fact]
|
|
public void AStepOfNoTimeHasNoAcceleration()
|
|
{
|
|
var (_, longinus, _) = EvFleet();
|
|
EvSettle(longinus);
|
|
longinus.MovementDirection = float2(0, 1);
|
|
for (var step = 0; step < 10; step++) longinus.Update(EvDt);
|
|
Assert.True(longinus.Acceleration.y > 1f, "fixture: it is burning");
|
|
longinus.Update(0f);
|
|
Assert.Equal(0f, longinus.Acceleration.x);
|
|
Assert.Equal(0f, longinus.Acceleration.y);
|
|
}
|
|
|
|
// A track of a ship that changes its vector along `direction` every fifth of a second, inside every window the tests use, at 60 m/s^2, for four seconds.
|
|
private static ManoeuvreTrack EvAlong(float2 direction, float window)
|
|
{
|
|
var track = default(ManoeuvreTrack);
|
|
for (var step = 0; step < 240; step++) track.Observe(direction * (step / 12 % 2 == 0 ? 60f : -60f), EvDt, window);
|
|
return track;
|
|
}
|
|
|
|
// Finding envelope-inputs-unpinned: the line of sight is rotated to find the axis across it, not reflected. Every other
|
|
// test reads a line of sight along an axis, where the two agree. Here the ship changes its vector along a direction
|
|
// that is not an axis: seen from a shooter along that direction it evades nothing, from one across it everything the
|
|
// track spreads over, and the envelope is roomy enough that the spread, not the reach, is what is read.
|
|
[Fact]
|
|
public void TheLineOfSightIsRotatedNotReflected()
|
|
{
|
|
const float window = .75f;
|
|
var roomy = new ManoeuvreEnvelope(200, 200, 200, 200, 20, 20);
|
|
var ahead = float2(0, 1);
|
|
foreach (var degrees in new[] { 30f, 45f, 120f, 200f })
|
|
{
|
|
var along = float2(cos(radians(degrees)), sin(radians(degrees)));
|
|
var across = float2(-along.y, along.x);
|
|
var track = EvAlong(along, window);
|
|
var full = .5f * track.LateralRms(along) * window * window;
|
|
Assert.True(full > 3f, $"fixture: the jink spreads ({full})");
|
|
Assert.InRange(FireControl.Evasion(roomy, ahead, track, across, window), full * .999f, full * 1.001f);
|
|
Assert.True(FireControl.Evasion(roomy, ahead, track, along, window) < .02f * full,
|
|
$"{degrees} degrees: along the jink {FireControl.Evasion(roomy, ahead, track, along, window)} against {full}");
|
|
}
|
|
}
|
|
|
|
// Finding envelope-inputs-unpinned: the capability is the envelope's reach from the ship's heading, not from the
|
|
// way it happens to be moving. A forward-heavy Longinus faces one way and drifts another, and the track is so wild
|
|
// that the reach is what is read (the spread is thousands of metres).
|
|
[Fact]
|
|
public void EvasionIsReadFromTheHeadingNotTheVelocity()
|
|
{
|
|
var (_, longinus, _) = EvFleet();
|
|
var window = _items.GameplaySettings.SolutionWindow;
|
|
EvSettle(longinus);
|
|
longinus.Update(EvDt);
|
|
var lineOfSight = normalize(float2(1, 2));
|
|
var across = float2(-lineOfSight.y, lineOfSight.x);
|
|
longinus.Direction = across;
|
|
longinus.Velocity = lineOfSight * 100f;
|
|
var wild = EvAlong(across, window);
|
|
wild.Moments *= 1e6f;
|
|
longinus.Manoeuvre = wild;
|
|
var envelope = longinus.Envelope;
|
|
float HalfWidth(float2 heading) => .5f * (FireControl.Reach(envelope, heading, across, window) + FireControl.Reach(envelope, heading, -across, window));
|
|
|
|
var evasion = FireControl.Evasion(longinus, lineOfSight);
|
|
Assert.InRange(evasion, HalfWidth(longinus.Direction) * .999f, HalfWidth(longinus.Direction) * 1.001f);
|
|
var fromTheVelocity = HalfWidth(normalize(longinus.Velocity));
|
|
Assert.True(Math.Abs(fromTheVelocity - evasion) > .1f * evasion, $"fixture: the two headings read differently ({evasion}, {fromTheVelocity})");
|
|
}
|
|
|
|
// Finding envelope-inputs-unpinned: Entity.Update observes the acceleration it measures at the solution window. The
|
|
// ship's own track after a two-second jink is the track a reference fed the same accelerations at that window makes.
|
|
[Fact]
|
|
public void TheShipsTrackObservesAtTheSolutionWindow()
|
|
{
|
|
var djinni = EvFleet().djinni;
|
|
var window = _items.GameplaySettings.SolutionWindow;
|
|
EvSettle(djinni);
|
|
var reference = djinni.Manoeuvre;
|
|
for (var step = 0; step < 120; step++)
|
|
{
|
|
djinni.MovementDirection = float2(step / 30 % 2 == 0 ? 1 : -1, 0);
|
|
djinni.Update(EvDt);
|
|
reference.Observe(djinni.Acceleration, EvDt, window);
|
|
}
|
|
Assert.True(reference.Moments.x > 1f, $"fixture: the jink is observed ({reference.Moments.x})");
|
|
Assert.InRange(djinni.Manoeuvre.Moments.x, reference.Moments.x * .999f, reference.Moments.x * 1.001f);
|
|
Assert.InRange(djinni.Manoeuvre.Moments.z, reference.Moments.z * .999f - .001f, reference.Moments.z * 1.001f + .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);
|
|
}
|
|
|
|
// The pure track forgets: after jinking, a track fed a constant acceleration (none at all, or a steady burn) reads
|
|
// exactly zero from the first tick at which the acceleration has held for the whole window, and not before, for each
|
|
// window. The change tick itself is tick 0.
|
|
private static int EvTicksToForget(float window, float2 held)
|
|
{
|
|
var track = EvAlong(float2(1, 0), window);
|
|
Assert.True(track.Moments.x > 1f, "fixture: the jink is observed");
|
|
var first = -1;
|
|
for (var tick = 0; tick < 300; tick++)
|
|
{
|
|
track.Observe(held, EvDt, window);
|
|
var zero = track.Moments.x == 0f && track.Moments.y == 0f && track.Moments.z == 0f && track.Innovation.x == 0f && track.Innovation.y == 0f;
|
|
if (first < 0 && zero) first = tick;
|
|
if (first >= 0) Assert.True(zero, $"window {window}: the track woke at tick {tick} after forgetting at {first}");
|
|
}
|
|
return first;
|
|
}
|
|
|
|
// Findings forget-window-unpinned and steady-burn-forget-unpinned: how long the track waits before it forgets is the
|
|
// window, whatever the window is, coasting or burning.
|
|
[Fact]
|
|
public void ATrackForgetsAWindowAfterTheLastChangeOfVector()
|
|
{
|
|
foreach (var window in new[] { .4f, .75f, 1.5f })
|
|
foreach (var held in new[] { float2(0, 0), float2(15, -8) })
|
|
{
|
|
var first = EvTicksToForget(window, held);
|
|
var ticks = (int) Math.Ceiling(window / EvDt);
|
|
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<Thruster>().Where(t => t.Item.Active.Value).ToList();
|
|
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);
|
|
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<Thruster>().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);
|
|
}
|
|
}
|
|
}
|