Sim speed: tests for the survivors Stryker found (thruster turn, tilted draw, exact step)

DrawAhead.Rotation delegates to Turned(pose, angle) so the turn is testable on a tilted pose, where the flat Duel poses make the quaternion product's off-axis terms vanish. Unverified at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 21:00:16 +02:00
commit 090b6d18dc
2 changed files with 56 additions and 3 deletions

View file

@ -14,13 +14,15 @@ public static class DrawAhead
entity.Position + float3(entity.Velocity.x, 0, entity.Velocity.y) * lead;
// The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds.
public static quaternion Rotation(Ship ship, float lead)
public static quaternion Rotation(Ship ship, float lead) => Turned(ship.Rotation, ship.TurnRate * lead);
// q turned about its own up axis by the angle, in the sense Direction turns for a positive TurnRate.
public static quaternion Turned(quaternion q, float angle)
{
var q = ship.Rotation;
var u = float3(q.x, q.y, q.z);
var up = float3(0, 1, 0);
up += 2 * (q.w * cross(u, up) + cross(u, cross(u, up)));
var half = ship.TurnRate * lead * .5f;
var half = angle * .5f;
var s = sin(half);
var t = new quaternion(up.x * s, up.y * s, up.z * s, cos(half));
// Hamilton product t * q: CultMath carries no quaternion product (a gap in CultMath, not filled here).

View file

@ -58,6 +58,11 @@ public sealed class SimClockTests
Assert.InRange(elapsed, 10.0, 10.1);
Assert.InRange(clock.Steps, Math.Round(elapsed * 60) - 1, Math.Round(elapsed * 60) + 1);
// A frame that pays for exactly one step runs it (binary-exact seconds, so nothing rounds).
var exact = new SimClock(.25f, 4);
Assert.Equal(1, exact.Advance(.25f, _ => { }));
Assert.Equal(0f, exact.Lead);
var slow = new SimClock(1f / 60f, 30);
for (var frame = 0; frame < 600; frame++) slow.Advance(1f / 60, _ => { });
Assert.InRange(slow.Steps, 299, 301);
@ -270,4 +275,50 @@ public sealed partial class RunStartTests
Assert.True(turn > .005f, "the step turns the ship enough to tell its sense");
Assert.True(error < .2f * turn, $"drawn one step ahead is where the sim puts the pose next (off by {error} of a {turn} turn)");
}
// Thrusters turn the same way: a Longinus held in a turn, settled, turns each step by exactly its TurnRate.
[Fact]
public void ThrustersReportTheirTurn()
{
var (_, longinus, _) = EvFleet();
EvSettle(longinus);
longinus.Turn = 1f;
var turned = 0;
for (var step = 0; step < 120; step++)
{
var before = longinus.Direction;
longinus.Update(ClockFrame);
Assert.InRange(abs(abs(SignedTurn(before, longinus.Direction)) - abs(longinus.TurnRate * ClockFrame)), 0f, 1e-6f);
if (abs(longinus.TurnRate) > 1e-4f)
{
turned++;
Assert.True(longinus.TurnRate > 0 == dot(longinus.Direction, before.Rotate(ItemRotation.Clockwise)) > 0);
}
}
Assert.True(turned > 30, "the thrusters turned the ship");
}
// The draw-ahead turn on a pose that is not flat: the up axis stays the pose's up, and the forward axis swings about
// it by the angle (Rodrigues' rotation, written out apart from DrawAhead's quaternion product).
[Fact]
public void TheDrawnTurnIsAboutThePosesOwnUp()
{
var up = normalize(float3(.3f, .8f, -.2f));
var pose = quaternion.LookRotation(normalize(cross(float3(1, 0, .4f), up)), up);
const float angle = .6f;
var turned = DrawAhead.Turned(pose, angle);
float3 Axis(quaternion q, float3 v)
{
var u = float3(q.x, q.y, q.z);
return v + 2 * (q.w * cross(u, v) + cross(u, cross(u, v)));
}
float3 Close(float3 a, float3 b) => float3(abs(a.x - b.x), abs(a.y - b.y), abs(a.z - b.z));
var forward = Axis(pose, float3(0, 0, 1));
var rodrigues = forward * cos(angle) + cross(Axis(pose, float3(0, 1, 0)), forward) * sin(angle);
var upBefore = Axis(pose, float3(0, 1, 0));
var tolerance = float3(1e-5f, 1e-5f, 1e-5f);
Assert.True(all(Close(Axis(turned, float3(0, 1, 0)), upBefore) < tolerance), "the pose's up does not move");
Assert.True(all(Close(Axis(turned, float3(0, 0, 1)), rodrigues) < tolerance), "the forward axis swings about it by the angle");
}
}