Scenario Briefs (Crossing, Duel, FusedRounds, LauncherAngles, StarvedReactor, WeaponFeel), scenario and test comments and test locals say the design names (Lightning Projector, Light Laser, Cell Blaster, Guided Missile Launcher, Swarm Rocket Pod, Muon Fusion Reactor, Cluster Cannon, Charged Scatter Blaster, Rugged Drive, Racing Drive) instead of the retired brand names; Crossing.PlightRange is ProjectorRange. generic-designs: the batch carries ExpectUnchanged so every record it does not author keeps its bytes (the plain commit re-encoded four records, dropping SensorData key 7). DesignTexts rewrites Large Drive, Tractor Beam, Smoke Proximity, Smoke Delayed, Smoke Refused and Smoke Refused Charge as tech-node text; the Tractor Beam's old line moves to its product Space Tractor. smoke-rounds' authored text names Light Laser and Charged Scatter Blaster to match. Tests: NoScenarioBriefNamesRetiredGear, TheCapacitorDesignCarriesItsRoleMap, NoDesignTextNamesAProduct, and a command test that widens every payload and checks apply changes only the three records it authors and restores the rewritten texts. Not built or run at this commit; GameData/Aetheria.cc is regenerated in the next commit, so NoDesignTextNamesAProduct and the command test fail here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
537 lines
24 KiB
C#
537 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using CultMath;
|
|
using Xunit;
|
|
|
|
namespace Aetheria.Shared.Tests;
|
|
|
|
// ThrustAllocator on synthetic columns. A column is (starboard m/s^2, forward m/s^2, clockwise yaw rad/s) per unit
|
|
// throttle; the fixtures are written in deg/s the way the probes measured them (map TA3, TA12).
|
|
public class ThrustAllocatorTests
|
|
{
|
|
const float Deg = MathF.PI / 180f;
|
|
|
|
// The drive carries a constant throttle of about 1.2e-4 (1.6e-4 on the halved hull) from the first touch of Turn, the
|
|
// optimum of the ridge against the cost rows; recruitment past the onset is 1e-3 and more within .005 of Turn.
|
|
const float LeakBound = 3e-4f;
|
|
|
|
static float3 Col(float starboard, float forward, float yawDeg) => new(starboard, forward, yawDeg * Deg);
|
|
|
|
// TA3, the Duel Longinus: two mismatched drives (0, 1) and two Rugged Drives (2 clockwise, 3 counter-clockwise).
|
|
static float3[] Duel() => new[]
|
|
{
|
|
Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(29.08f, 0, 166.26f), Col(-21.80f, 0, -124.63f),
|
|
};
|
|
|
|
// The clockwise Rugged Drive lost.
|
|
static float3[] DamagedDuel() => new[]
|
|
{
|
|
Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), default, Col(-21.80f, 0, -124.63f),
|
|
};
|
|
|
|
// TA12's symmetric hull: mains, fore and aft laterals in torque-free pairs, a bow thruster.
|
|
static float3[] Symmetric() => new[]
|
|
{
|
|
Col(0, 50, 20), Col(0, 50, -20), Col(20, 0, 80), Col(-20, 0, -80), Col(20, 0, -60), Col(-20, 0, 60), Col(0, -30, 0),
|
|
};
|
|
|
|
// The hull's half-axis extremes, from the definition: the sum of a row's positive entries, or of the negated
|
|
// negative ones. Axis 0 starboard, 1 forward, 2 clockwise yaw.
|
|
static (float plus, float minus) Extremes(float3[] columns, int axis)
|
|
{
|
|
float plus = 0, minus = 0;
|
|
foreach (var c in columns)
|
|
{
|
|
var v = axis == 0 ? c.x : axis == 1 ? c.y : c.z;
|
|
if (v > 0) plus += v; else minus -= v;
|
|
}
|
|
return (plus, minus);
|
|
}
|
|
|
|
static float3 Net(float3[] columns, float[] throttle)
|
|
{
|
|
float3 net = default;
|
|
for (var i = 0; i < columns.Length; i++) net += columns[i] * throttle[i];
|
|
return net;
|
|
}
|
|
|
|
static float[] Solve(ThrustAllocator allocator, float3[] columns, float moveX, float moveY, float turn)
|
|
{
|
|
var throttle = new float[columns.Length];
|
|
allocator.Allocate(columns, new float2(moveX, moveY), turn, throttle);
|
|
return throttle;
|
|
}
|
|
|
|
static float[] Solve(float3[] columns, float moveX, float moveY, float turn) => Solve(new ThrustAllocator(), columns, moveX, moveY, turn);
|
|
|
|
static float YawDeg(float3[] columns, float[] throttle) => Net(columns, throttle).z / Deg;
|
|
|
|
[Fact]
|
|
public void AHoldIsExactWithMismatchedDrives()
|
|
{
|
|
var duel = Duel();
|
|
var throttle = Solve(duel, 0, 1, 0);
|
|
var clockwise = Extremes(duel, 2).plus;
|
|
var net = Net(duel, throttle);
|
|
Assert.True(MathF.Abs(net.z) <= 1e-5f * clockwise, $"yaw {net.z / Deg} deg/s");
|
|
Assert.True(net.y >= .99f * Extremes(duel, 1).plus, $"forward {net.y}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(1)]
|
|
public void ALostDriveIsBalancedByWhatIsLeft(int zeroed)
|
|
{
|
|
var columns = Duel();
|
|
var extreme = Extremes(columns, 2).plus;
|
|
columns[zeroed] = default;
|
|
var throttle = Solve(columns, 0, 1, 0);
|
|
var net = Net(columns, throttle);
|
|
Assert.True(MathF.Abs(net.z) <= 1e-5f * extreme, $"yaw {net.z / Deg} deg/s");
|
|
Assert.Equal(0f, throttle[zeroed]);
|
|
Assert.True(net.y > 0, $"forward {net.y}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1f)]
|
|
[InlineData(.5f)]
|
|
[InlineData(-.5f)]
|
|
[InlineData(-1f)]
|
|
public void ATurnIsThatFractionOfTheBoxExtreme(float turn)
|
|
{
|
|
var duel = Duel();
|
|
var (plus, minus) = Extremes(duel, 2);
|
|
var expected = turn * (turn > 0 ? plus : minus);
|
|
var net = Net(duel, Solve(duel, 0, 0, turn));
|
|
Assert.True(MathF.Abs(net.z - expected) <= 1e-4f * MathF.Abs(expected), $"yaw {net.z / Deg} expected {expected / Deg}");
|
|
}
|
|
|
|
[Fact]
|
|
public void AttitudeServesFirstAndTheOnsetIsTheColumnsRatio()
|
|
{
|
|
var duel = Duel();
|
|
// The onset is the attitude thruster's yaw over the hull's extreme in that direction.
|
|
var clockwiseOnset = duel[2].z / Extremes(duel, 2).plus;
|
|
var counterOnset = -duel[3].z / Extremes(duel, 2).minus;
|
|
Assert.InRange(clockwiseOnset, .7714f, .7716f);
|
|
Assert.InRange(counterOnset, .6904f, .6906f);
|
|
var allocator = new ThrustAllocator();
|
|
for (var step = 0; step <= 1000; step++)
|
|
{
|
|
var turn = step * .001f;
|
|
var drive = Solve(allocator, duel, 0, 0, turn)[0];
|
|
if (turn <= clockwiseOnset - .005f) Assert.True(drive <= LeakBound, $"clockwise turn {turn}: drive {drive}");
|
|
if (turn >= clockwiseOnset + .005f) Assert.True(drive > 1e-3f, $"clockwise turn {turn}: drive {drive}");
|
|
}
|
|
allocator = new ThrustAllocator();
|
|
for (var step = 0; step <= 1000; step++)
|
|
{
|
|
var turn = step * .001f;
|
|
var drive = Solve(allocator, duel, 0, 0, -turn)[1];
|
|
if (turn <= counterOnset - .005f) Assert.True(drive <= LeakBound, $"counter-clockwise turn {turn}: drive {drive}");
|
|
if (turn >= counterOnset + .005f) Assert.True(drive > 1e-3f, $"counter-clockwise turn {turn}: drive {drive}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheOnsetMovesWithTheColumns()
|
|
{
|
|
// Clockwise Rugged Drive lost: the drive serves a clockwise turn from the first touch.
|
|
var damaged = DamagedDuel();
|
|
var small = Solve(damaged, 0, 0, .01f);
|
|
Assert.True(small[0] > 0, $"drive {small[0]}");
|
|
Assert.True(MathF.Abs(YawDeg(damaged, small) - .01f * 49.24f) <= .01f * 49.24f * 1e-3f, $"yaw {YawDeg(damaged, small)}");
|
|
var full = Solve(damaged, 0, 0, 1f);
|
|
Assert.True(MathF.Abs(YawDeg(damaged, full) - 49.24f) <= 49.24f * 1e-3f, $"yaw {YawDeg(damaged, full)}");
|
|
|
|
// Clockwise Rugged Drive halved: the onset is its new share of the clockwise extreme.
|
|
var halved = Duel();
|
|
halved[2] = halved[2] * .5f;
|
|
var onset = halved[2].z / Extremes(halved, 2).plus;
|
|
Assert.InRange(onset, .627f, .629f);
|
|
var allocator = new ThrustAllocator();
|
|
for (var step = 0; step <= 1000; step++)
|
|
{
|
|
var turn = step * .001f;
|
|
var drive = Solve(allocator, halved, 0, 0, turn)[0];
|
|
if (turn <= onset - .005f) Assert.True(drive <= LeakBound, $"turn {turn}: drive {drive}");
|
|
if (turn >= onset + .005f) Assert.True(drive > 1e-3f, $"turn {turn}: drive {drive}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryThrottleIsContinuousInTurn()
|
|
{
|
|
foreach (var columns in new[] { Duel(), DamagedDuel(), Symmetric() })
|
|
{
|
|
var allocator = new ThrustAllocator();
|
|
var previous = Solve(allocator, columns, 0, 0, -1f);
|
|
for (var step = -999; step <= 1000; step++)
|
|
{
|
|
var current = Solve(allocator, columns, 0, 0, step * .001f);
|
|
for (var i = 0; i < columns.Length; i++)
|
|
Assert.True(MathF.Abs(current[i] - previous[i]) <= .02f, $"turn {step * .001f}: throttle {i} {previous[i]} -> {current[i]}");
|
|
previous = current;
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PureTorqueNeedsNoTranslation()
|
|
{
|
|
var hull = Symmetric();
|
|
var allocator = new ThrustAllocator();
|
|
var starboard = Extremes(hull, 0).plus;
|
|
var forward = Extremes(hull, 1).plus;
|
|
for (var step = -87; step <= 87; step++)
|
|
{
|
|
var net = Net(hull, Solve(allocator, hull, 0, 0, step * .01f));
|
|
Assert.True(MathF.Abs(net.x) <= 1e-3f * starboard, $"turn {step * .01f}: starboard {net.x}");
|
|
Assert.True(MathF.Abs(net.y) <= 1e-3f * forward, $"turn {step * .01f}: forward {net.y}");
|
|
}
|
|
var strafe = Net(hull, Solve(hull, 1, 0, 0));
|
|
Assert.True(strafe.x >= .9f * starboard, $"strafe {strafe.x} of {starboard}");
|
|
Assert.True(MathF.Abs(strafe.z) <= 1e-3f * Extremes(hull, 2).plus, $"yaw {strafe.z / Deg}");
|
|
}
|
|
|
|
[Fact]
|
|
public void TurnComesBeforeTranslation()
|
|
{
|
|
var duel = Duel();
|
|
var throttle = Solve(duel, 0, 1, 1f);
|
|
var net = Net(duel, throttle);
|
|
var extreme = Extremes(duel, 2).plus;
|
|
Assert.True(MathF.Abs(net.z - extreme) <= 1e-4f * extreme, $"yaw {net.z / Deg} of {extreme / Deg}");
|
|
Assert.True(throttle[1] < .01f, $"counter-turning drive {throttle[1]}");
|
|
}
|
|
|
|
[Fact]
|
|
public void MovementIsThatFractionOfTheHalfAxis()
|
|
{
|
|
// A torque-free strafe pair of unequal strength and a torque-free main.
|
|
var columns = new[] { Col(30, 0, 0), Col(-20, 0, 0), Col(0, 60, 0) };
|
|
const float yaw = 1e-4f;
|
|
var right = Net(columns, Solve(columns, .5f, 0, 0));
|
|
Assert.True(MathF.Abs(right.x - 15f) <= 15e-3f && MathF.Abs(right.z) <= yaw, $"right {right.x}");
|
|
var left = Net(columns, Solve(columns, -.5f, 0, 0));
|
|
Assert.True(MathF.Abs(left.x + 10f) <= 10e-3f && MathF.Abs(left.z) <= yaw, $"left {left.x}");
|
|
var ahead = Net(columns, Solve(columns, 0, .5f, 0));
|
|
Assert.True(MathF.Abs(ahead.y - 30f) <= 30e-3f && MathF.Abs(ahead.z) <= yaw, $"ahead {ahead.y}");
|
|
}
|
|
|
|
[Fact]
|
|
public void IntentBeyondOneIsClamped()
|
|
{
|
|
var duel = Duel();
|
|
Assert.Equal(Solve(duel, 0, 0, 1f), Solve(duel, 0, 0, 3f));
|
|
Assert.Equal(Solve(duel, 1, -1, 0), Solve(duel, 3, -3, 0));
|
|
Assert.Equal(Solve(duel, 0, 0, -1f), Solve(duel, 0, 0, -3f));
|
|
}
|
|
|
|
[Fact]
|
|
public void NothingAskedNothingFired()
|
|
{
|
|
Assert.All(Solve(Duel(), 0, 0, 0), t => Assert.Equal(0f, t));
|
|
Assert.All(Solve(new float3[4], 1, 1, 1), t => Assert.Equal(0f, t));
|
|
new ThrustAllocator().Allocate(ReadOnlySpan<float3>.Empty, new float2(1, 1), 1, Span<float>.Empty);
|
|
var withNaN = Duel();
|
|
withNaN[2] = new float3(float.NaN, 0, 0);
|
|
Assert.All(Solve(withNaN, 0, 1, 1), t => Assert.Equal(0f, t));
|
|
var nanYaw = Duel();
|
|
nanYaw[0] = new float3(0, 1, float.NaN);
|
|
Assert.All(Solve(nanYaw, 0, 1, 0), t => Assert.Equal(0f, t));
|
|
|
|
// A bad column zeroes the answer even after a good call left throttles in the warm start and in the caller's buffer.
|
|
var allocator = new ThrustAllocator();
|
|
var buffer = new float[4];
|
|
allocator.Allocate(Duel(), new float2(0, 1), .5f, buffer);
|
|
Assert.Contains(buffer, t => t > .1f);
|
|
allocator.Allocate(withNaN, new float2(0, 1), .5f, buffer);
|
|
Assert.All(buffer, t => Assert.Equal(0f, t));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheStartDoesNotChangeTheAnswer()
|
|
{
|
|
var duel = Duel();
|
|
var cold = Solve(duel, .3f, .8f, .6f);
|
|
foreach (var previous in new[] { (1f, 1f, 1f), (-1f, -.5f, -1f), (0f, 0f, 0f) })
|
|
{
|
|
var allocator = new ThrustAllocator();
|
|
Solve(allocator, duel, previous.Item1, previous.Item2, previous.Item3);
|
|
var warm = Solve(allocator, duel, .3f, .8f, .6f);
|
|
for (var i = 0; i < duel.Length; i++) Assert.True(MathF.Abs(warm[i] - cold[i]) <= 1e-5f, $"previous {previous}: throttle {i} {cold[i]} vs {warm[i]}");
|
|
}
|
|
}
|
|
|
|
// A cruiser-like hull, n thrusters each pushing along one body axis with a lever-arm yaw (the finding's fixture
|
|
// generator: Synthetic(8, 7005)).
|
|
static float3[] Synthetic(int n, int seed)
|
|
{
|
|
var rng = new System.Random(seed);
|
|
var cols = new float3[n];
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
var a = 20f + 40f * (float)rng.NextDouble();
|
|
var yaw = (float)(rng.NextDouble() * 2 - 1) * 120f;
|
|
var dir = rng.Next(10);
|
|
cols[i] = dir < 4 ? Col(0, a, yaw) : dir < 6 ? Col(0, -a, yaw) : dir < 8 ? Col(a, 0, yaw) : Col(-a, 0, yaw);
|
|
}
|
|
return cols;
|
|
}
|
|
|
|
// Finding solver-converged-early: on this hull a fresh allocator's eighth intent of a seeded random walk stopped at a
|
|
// non-optimal point (throttle 7 at 1.0 against .922 warm). The answer is the same from a cold and from a warm start.
|
|
[Fact]
|
|
public void ColdAndWarmStartsAgreeOnTheHullThatStoppedEarly()
|
|
{
|
|
var hull = Synthetic(8, 7005);
|
|
var rng = new System.Random(5);
|
|
var warm = new ThrustAllocator();
|
|
for (var step = 0; step < 8; step++)
|
|
{
|
|
float x = (float)rng.NextDouble() * 2 - 1, y = (float)rng.NextDouble() * 2 - 1, turn = (float)rng.NextDouble() * 2 - 1;
|
|
var fromWarm = Solve(warm, hull, x, y, turn);
|
|
var fromCold = Solve(hull, x, y, turn);
|
|
for (var i = 0; i < hull.Length; i++) Assert.True(MathF.Abs(fromWarm[i] - fromCold[i]) <= 1e-5f, $"step {step}: throttle {i} {fromCold[i]} cold vs {fromWarm[i]} warm");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EqualThrustersShareTheDemand()
|
|
{
|
|
var columns = new[] { Col(0, 50, 0), Col(0, 50, 0) };
|
|
var throttle = Solve(columns, 0, .5f, 0);
|
|
Assert.True(MathF.Abs(throttle[0] - throttle[1]) <= 1e-4f, $"{throttle[0]} vs {throttle[1]}");
|
|
Assert.True(throttle[0] > .1f);
|
|
}
|
|
|
|
[Fact]
|
|
public void AColumnCountChangeResets()
|
|
{
|
|
var four = Duel();
|
|
var three = new[] { four[0], four[2], four[3] };
|
|
var allocator = new ThrustAllocator();
|
|
var steps = new List<(float3[] columns, float x, float y, float turn)>
|
|
{
|
|
(four, .2f, .9f, -.7f), (three, -.4f, 1f, .9f), (four, .2f, .9f, -.7f),
|
|
};
|
|
foreach (var (columns, x, y, turn) in steps)
|
|
{
|
|
var warm = Solve(allocator, columns, x, y, turn);
|
|
var fresh = Solve(columns, x, y, turn);
|
|
for (var i = 0; i < columns.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"{columns.Length} columns: throttle {i}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AShipWithOneThrusterIsFlownFromItsFirstCall()
|
|
{
|
|
var throttle = Solve(new[] { Col(0, 50, 0) }, 0, 1, 0);
|
|
Assert.True(throttle[0] > .99f, $"{throttle[0]}");
|
|
}
|
|
|
|
[Fact]
|
|
public void ChangedColumnsOfTheSameCountLeaveNothingBehind()
|
|
{
|
|
// Thruster 1 first strafes, then only pushes forward: the strafe row it left must not shape the second answer.
|
|
var strafing = new[] { Col(0, 60, 0), Col(30, 0, 0), Col(-20, 0, 0) };
|
|
var forwardOnly = new[] { Col(0, 60, 0), Col(0, 40, 0), Col(0, -20, 0) };
|
|
var allocator = new ThrustAllocator();
|
|
Solve(allocator, strafing, 1, 1, .5f);
|
|
var warm = Solve(allocator, forwardOnly, 0, 1, 0);
|
|
var fresh = Solve(forwardOnly, 0, 1, 0);
|
|
for (var i = 0; i < warm.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"throttle {i}: {warm[i]} vs {fresh[i]}");
|
|
Assert.True(warm[1] > .9f, $"{warm[1]}");
|
|
}
|
|
|
|
[Fact]
|
|
public void AtTheIterationCapTheIterateIsKept()
|
|
{
|
|
// 128 torque-free forward thrusters from a cold start: the active set frees about one throttle per iteration, so
|
|
// the solver stops at its iteration cap (measured: IterationLimit at 100). TA-R4 keeps the iterate it reached,
|
|
// which already carries a share of the forward demand; a call that zeroed it would give nothing.
|
|
var forward = new float3[128];
|
|
for (var i = 0; i < forward.Length; i++) forward[i] = Col(0, 30f + i % 7, 0);
|
|
var throttle = Solve(forward, 0, 1, 0);
|
|
var reached = Net(forward, throttle).y;
|
|
var extreme = Extremes(forward, 1).plus;
|
|
Assert.True(reached > .1f * extreme, $"{reached} of {extreme}");
|
|
}
|
|
|
|
[Fact]
|
|
public void AFullTurnAgainstAFullReverseKeepsItsWholeYaw()
|
|
{
|
|
// The only clockwise actuator also pushes forward and the only reverse actuator turns the other way. Delivering
|
|
// a full turn against a full reverse leaves a forward error of 2, the top of TA-R2's error box: an error bound
|
|
// below 2 would take yaw back to pay for it, and turn-authority-full says the turn is whole.
|
|
foreach (var (columns, x, y) in new[]
|
|
{
|
|
(new[] { Col(0, 50, 100), Col(0, -50, -100) }, 0f, -1f),
|
|
(new[] { Col(50, 0, 100), Col(-50, 0, -100) }, -1f, 0f),
|
|
(new[] { Col(0, -50, 100), Col(0, 50, -100) }, 0f, 1f),
|
|
(new[] { Col(-50, 0, 100), Col(50, 0, -100) }, 1f, 0f),
|
|
})
|
|
{
|
|
var yaw = Net(columns, Solve(columns, x, y, 1)).z;
|
|
var extreme = Extremes(columns, 2).plus;
|
|
Assert.True(MathF.Abs(yaw - extreme) <= 1e-4f * extreme, $"move ({x},{y}): yaw {yaw / Deg} of {extreme / Deg}");
|
|
}
|
|
}
|
|
|
|
// Forty thrusters of 20..60 m/s^2 on random half-axes with random yaw, the shape of a hull far larger than any demo
|
|
// ship. Deterministic from the seed.
|
|
static float3[] LargeHull(int n, int seed)
|
|
{
|
|
var rng = new System.Random(seed);
|
|
var columns = new float3[n];
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
var a = 20f + 40f * (float)rng.NextDouble();
|
|
var yaw = (float)(rng.NextDouble() * 2 - 1) * 120f;
|
|
var dir = rng.Next(10);
|
|
columns[i] = dir < 4 ? Col(0, a, yaw) : dir < 6 ? Col(0, -a, yaw) : dir < 8 ? Col(a, 0, yaw) : Col(-a, 0, yaw);
|
|
}
|
|
return columns;
|
|
}
|
|
|
|
[Fact]
|
|
public void NothingAskedAfterAnIterationCapCallFiresNothing()
|
|
{
|
|
// On this hull the warm solve of a zero-intent call needs more than the solver's 100 iterations, and the iterate it
|
|
// stopped at fired throttles up to 1.0 with nothing asked. Whether nothing is asked is decided from the intent, not
|
|
// by how far the solver gets.
|
|
var histories = new[]
|
|
{
|
|
(Seed: 9039, History: new[] { (0.0112729073f, -0.170811713f, 0.809294581f), (-0.474286854f, 0.703138232f, 0f), (0.294080615f, 0.934491992f, -0.776120305f) }),
|
|
(Seed: 9079, History: new[] { (0.607828975f, 1f, 0.202640772f), (-0.620587587f, 0.0162956715f, -0.898228765f), (0.0739500523f, 1f, -0.903907537f) }),
|
|
};
|
|
foreach (var (seed, history) in histories)
|
|
{
|
|
var hull = LargeHull(40, seed);
|
|
var allocator = new ThrustAllocator();
|
|
foreach (var (x, y, turn) in history) Solve(allocator, hull, x, y, turn);
|
|
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
|
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ASmallIntentIsStillAnsweredWhateverTheColumnScale()
|
|
{
|
|
// Zero is a fact about the intent alone: an intent of 1e-3 of the half-axis is served at any scale of thrust.
|
|
foreach (var scale in new[] { 1e-3f, 1f, 1e3f })
|
|
{
|
|
var hull = Duel();
|
|
for (var i = 0; i < hull.Length; i++) hull[i] *= scale;
|
|
var extreme = Extremes(hull, 2).plus;
|
|
var yaw = Net(hull, Solve(hull, 0, 0, 1e-3f)).z;
|
|
Assert.True(yaw > .5e-3f * extreme, $"scale {scale}: yaw {yaw} of {extreme}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NothingAskedAfterACallFiresNothing()
|
|
{
|
|
// The warm start left by a call must not leak into a hold of zero intent: exactly zero, not solver residue.
|
|
foreach (var hull in new[] { Duel(), Symmetric() })
|
|
foreach (var previous in new[] { (.5f, .5f, .5f), (1f, 1f, 1f), (-.3f, .8f, .6f), (0f, 0f, -1f), (0f, 1f, 0f) })
|
|
{
|
|
var allocator = new ThrustAllocator();
|
|
Solve(allocator, hull, previous.Item1, previous.Item2, previous.Item3);
|
|
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheDriveCarriesTheLinearCostsLeakBeforeTheOnset()
|
|
{
|
|
// The error variables are paid linearly toward zero (TA-R2): that cost, against the ridge, holds the drive at a
|
|
// small constant throttle before the onset, 1.30e-4 at turn .5 and 1.14e-4 at turn -.5 on the Duel (a cost row
|
|
// pulling the errors the other way gives 1.06e-4 and 0.93e-4). The bounds are the measured value less 8%.
|
|
var duel = Duel();
|
|
Assert.InRange(Solve(duel, 0, 0, .5f)[0], 1.2e-4f, LeakBound);
|
|
Assert.InRange(Solve(duel, 0, 0, -.5f)[1], 1.05e-4f, LeakBound);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheForwardFloorHoldsEveryColumnThatPushesForward()
|
|
{
|
|
// The throttle lock (ruling overdrive-forward-floor): a column is floored by the sign of its own forward push, not
|
|
// by its size or its torque. A sliver of forward push is held at full through a hard turn either way, and the
|
|
// columns that push nowhere or back are free.
|
|
var hull = new[] { Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(0, .01f, -80f), Col(29.08f, 0, 166.26f), Col(0, -40f, 90f) };
|
|
var allocator = new ThrustAllocator();
|
|
foreach (var turn in new[] { 1f, -1f })
|
|
{
|
|
var throttle = new float[hull.Length];
|
|
allocator.Allocate(hull, new float2(1, -1), turn, throttle, forwardFloor: true);
|
|
Assert.All(throttle.Take(3), t => Assert.True(t > .9999f, "a forward-pushing column holds full"));
|
|
|
|
var blind = new float[hull.Length];
|
|
new ThrustAllocator().Allocate(hull, default, turn, blind, forwardFloor: true);
|
|
var apart = throttle.Zip(blind, (a, b) => MathF.Abs(a - b)).Max();
|
|
Assert.True(apart < 1e-6f, $"the stick changed the floored throttles by {apart}");
|
|
|
|
var open = new float[hull.Length];
|
|
new ThrustAllocator().Allocate(hull, new float2(0, 1), turn, open);
|
|
Assert.True(open.Take(3).Min() < .5f, "fixture: unfloored, the turn idles a forward drive");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheThrottlesStayInBoundsWhateverTheSolveEnds()
|
|
{
|
|
// The solver can stop at its iteration limit rather than converge. Probed on 64000 calls over 1 to 32 columns, scales
|
|
// 1e-6 to 1e6, micro and ordinary intents (2026-10-10): 8 of 60297 solves reached it, all on 32 synthetic columns, all with
|
|
// intents near 1e-8, none from a fresh allocator, none on a shipped hull (the largest has 8 thrusters). Wherever the
|
|
// solve ends, the allocator hands back what it has: finite, within [0,1], and the floored columns at full.
|
|
var rng = new System.Random(20261010);
|
|
float Rand(float lo, float hi) => lo + (float) rng.NextDouble() * (hi - lo);
|
|
foreach (var scale in new[] { 1e-3f, 1f })
|
|
for (var round = 0; round < 12; round++)
|
|
{
|
|
var hull = Enumerable.Range(0, 32).Select(_ => new float3(Rand(-1, 1) * scale, Rand(-1, 1) * scale, rng.Next(4) == 0 ? 0 : Rand(-.3f, .3f) * scale)).ToArray();
|
|
var allocator = new ThrustAllocator();
|
|
var throttle = new float[hull.Length];
|
|
for (var call = 0; call < 40; call++)
|
|
{
|
|
var micro = rng.Next(4) == 0 ? 1e-7f : 1f;
|
|
var floor = rng.Next(3) == 0;
|
|
allocator.Allocate(hull, new float2(Rand(-1, 1) * micro, Rand(-1, 1) * micro), Rand(-1.5f, 1.5f) * micro, throttle, floor);
|
|
Assert.All(throttle, t => Assert.InRange(t, 0f, 1f));
|
|
if (floor)
|
|
for (var i = 0; i < hull.Length; i++)
|
|
if (hull[i].y > 0) Assert.Equal(1f, throttle[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AMicroIntentIsServed()
|
|
{
|
|
// Zero is a fact about the intent alone (follow-up allocator-residual-pins): an intent of 1e-6 or 1e-10 of the
|
|
// half-axis is not zero, so something fires and the net goes the way it was asked.
|
|
void Served(float3[] hull, float x, float y, float turn, string what)
|
|
{
|
|
var throttle = Solve(hull, x, y, turn);
|
|
var net = Net(hull, throttle);
|
|
var asked = x != 0 ? x : y != 0 ? y : turn;
|
|
var got = x != 0 ? net.x : y != 0 ? net.y : net.z;
|
|
Assert.True(throttle.Max() > 0f, $"{what}: nothing fired");
|
|
Assert.True(got * asked > 0f, $"{what}: the net does not go the way it was asked");
|
|
}
|
|
|
|
foreach (var micro in new[] { 1e-6f, 1e-10f })
|
|
{
|
|
Served(Duel(), 0, 0, micro, "duel turn +");
|
|
Served(Duel(), 0, 0, -micro, "duel turn -");
|
|
Served(Duel(), 0, micro, 0, "duel forward");
|
|
Served(Symmetric(), micro, 0, 0, "symmetric starboard");
|
|
Served(Symmetric(), -micro, 0, 0, "symmetric port");
|
|
Served(Symmetric(), 0, micro, 0, "symmetric forward");
|
|
Served(Symmetric(), 0, -micro, 0, "symmetric reverse");
|
|
}
|
|
}
|
|
}
|