Aetheria/tests/Aetheria.Shared.Tests/SimClockTests.cs
2026-10-23 06:09:12 +02:00

357 lines
16 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.Globalization;
using System.Linq;
using System.Text;
using CultMath;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// SimClock owns sim time's relation to real time: frames of real seconds in, whole fixed steps out. Nothing here hands
// a clock to a presenter; DrawAhead is pure reads of the latest state.
public sealed class SimClockTests
{
private static readonly float[] Irregular = { 1f / 144, 1f / 30, 1f / 90 };
// Runs frames cycling through the lengths until the clock has run the steps; returns the step values it passed.
private static List<float> Drive(SimClock clock, float[] frames, long steps)
{
var passed = new List<float>();
for (var frame = 0; clock.Steps < steps; frame++)
clock.Advance(frames[frame % frames.Length], dt => passed.Add(dt));
return passed;
}
[Fact]
public void TheStepNeverChanges()
{
foreach (var frames in new[] { new[] { 1f / 60 }, Irregular, new[] { .09f, .004f, 0f, 3f } })
{
var clock = new SimClock(1f / 60f, 60);
var passed = Drive(clock, frames, 500);
Assert.True(passed.Count >= 500);
Assert.All(passed, dt => Assert.Equal(clock.Step, dt));
}
var slow = new SimClock(.1f, 6);
Assert.All(Drive(slow, Irregular, 50), dt => Assert.Equal(.1f, dt));
}
[Fact]
public void StepsFollowRealTime()
{
// Ten real seconds of irregular frames.
var clock = new SimClock(1f / 60f, 60);
var elapsed = 0.0;
for (var frame = 0; elapsed < 10.0; frame++)
{
var seconds = Irregular[frame % Irregular.Length];
elapsed += seconds;
clock.Advance(seconds, _ => { });
}
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, 16);
Assert.Equal(1, exact.Advance(.0625f, _ => { }));
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);
}
[Fact]
public void AHitchIsClamped()
{
var clock = new SimClock(1f / 60f, 60);
var ran = clock.Advance(5f, _ => { });
Assert.Equal(6, ran);
Assert.Equal(6, clock.Steps);
}
[Fact]
public void AStepCannotResetItsClock()
{
var clock = new SimClock(1f / 60f, 60);
Assert.Throws<InvalidOperationException>(() => clock.Advance(1f / 60f, _ => clock.Reset()));
clock.Advance(.005f, _ => { });
Assert.True(clock.Lead > 0);
clock.Reset();
Assert.Equal(0f, clock.Lead);
Assert.Equal(1, clock.Advance(1f / 60f, _ => { }));
}
[Fact]
public void PauseFeedsNothing()
{
var clock = new SimClock(1f / 60f, 60);
clock.Advance(.025f, _ => { });
var lead = clock.Lead;
var steps = clock.Steps;
Assert.True(lead > 0);
foreach (var seconds in new[] { 0f, -1f })
{
Assert.Equal(0, clock.Advance(seconds, _ => throw new InvalidOperationException()));
Assert.Equal(lead, clock.Lead);
Assert.Equal(steps, clock.Steps);
}
}
[Fact]
public void LeadIsHonest()
{
var clock = new SimClock(.1f, 6);
double sinceLastStep = 0;
for (var frame = 0; frame < 400; frame++)
{
// The real time since the last step: what was left over plus this frame, less the steps it paid for.
sinceLastStep += Irregular[frame % Irregular.Length];
var seconds = Irregular[frame % Irregular.Length];
clock.Advance(seconds, _ => sinceLastStep -= 1.0 / clock.StepsPerRealSecond);
Assert.InRange(clock.Lead, 0f, clock.Step * .99999f);
Assert.Equal(sinceLastStep * clock.StepsPerRealSecond * clock.Step, clock.Lead, 4);
}
}
[Fact]
public void ABadClockIsRefusedByFieldName()
{
Assert.Equal("step", Assert.Throws<ArgumentOutOfRangeException>(() => new SimClock(0f, 60)).ParamName);
Assert.Equal("stepsPerRealSecond", Assert.Throws<ArgumentOutOfRangeException>(() => new SimClock(1f, 0f)).ParamName);
}
}
public sealed partial class RunStartTests
{
private const float ClockFrame = SimClock.GameStep;
private const int HistorySteps = 1800;
// The Duel launched as the game launches it, its item random reseeded so play draws the same numbers every time.
private (Zone arena, Ship player) LaunchDuel()
{
var launched = Launch(new Duel());
Assert.Empty(launched.failures);
_items.Random = new CultMath.Random(0xC0FFEE);
launched.staged.Player.Command.Movement = float2(0, 1);
launched.staged.Player.Command.Turn = TurnCommand.OfRate(.5f);
foreach (var ship in launched.arena.Entities.OfType<Ship>())
ship.SetTarget(launched.arena.Entities.OfType<Ship>().First(other => other != ship));
return (launched.arena, launched.staged.Player);
}
private static string R(float value) => value.ToString("R", CultureInfo.InvariantCulture);
// Everything the step decides, as text: the zone clock, each entity's pose and condition, and each shot resolved.
private static string Snapshot(Zone zone, List<string> shots)
{
var text = new StringBuilder(R(zone.Time));
foreach (var entity in zone.Entities)
{
text.Append($"|{entity.Name} {R(entity.Position.x)},{R(entity.Position.z)} v{R(entity.Velocity.x)},{R(entity.Velocity.y)} " +
$"d{R(entity.Direction.x)},{R(entity.Direction.y)} w{R(entity.TurnRate)} h{R(entity.Hull.Durability)}");
if (entity.Shield != null) text.Append($" s{R(entity.Shield.Progress)}");
if (entity.Temperature != null)
{
var heat = 0f;
foreach (var cell in entity.Temperature) heat += cell;
text.Append($" t{R(heat)}");
}
}
text.Append($"|shots {string.Join(",", shots)}");
shots.Clear();
return text.ToString();
}
// Feeds the zone through one SimClock with the frames cycled, snapshotting every step until HistorySteps have run.
private static List<string> History(Zone zone, float[] frames)
{
var clock = new SimClock(1f / 60f, 60);
var shots = new List<string>();
using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}"));
var history = new List<string>();
for (var frame = 0; history.Count < HistorySteps; frame++)
clock.Advance(frames[frame % frames.Length], dt =>
{
// Both ships open fire every 90th step, on the step count, so the fight is the same fight whatever
// the frames were.
if (history.Count % 90 == 0)
foreach (var ship in zone.Entities.OfType<Ship>())
foreach (var weapon in ship.GetBehaviors<Weapon>())
FireControl.Fire(weapon, weapon.Item, ship);
zone.Update(dt);
history.Add(Snapshot(zone, shots));
});
return history.Take(HistorySteps).ToList();
}
// Control: the same launch driven twice the same way is the same history. If this fails the simulation is
// nondeterministic before the clock is involved, and FrameTimesDoNotChangeHistory proves nothing.
[Fact]
public void OneHistoryTwice()
{
var first = History(LaunchDuel().arena, new[] { ClockFrame });
var second = History(LaunchDuel().arena, new[] { ClockFrame });
Assert.Equal(first, second);
Assert.Contains(first, line => !line.EndsWith("|shots ")); // shots were fired and resolved
}
[Fact]
public void FrameTimesDoNotChangeHistory()
{
var steady = History(LaunchDuel().arena, new[] { ClockFrame });
var irregular = History(LaunchDuel().arena, new[] { 1f / 144, 1f / 30, 1f / 90 });
Assert.Equal(steady.Count, irregular.Count);
for (var step = 0; step < steady.Count; step++)
Assert.True(steady[step] == irregular[step], $"step {step}: {steady[step]} != {irregular[step]}");
}
private static float SignedTurn(float2 before, float2 after) =>
atan2(before.x * after.y - before.y * after.x, dot(before, after));
[Fact]
public void TurnRateIsTheActuatorsTurn()
{
var (arena, player) = LaunchDuel();
Assert.Equal(0f, player.TurnRate); // staged: nothing has turned it
var turned = 0;
for (var step = 0; step < 240; step++)
{
var before = player.Direction;
arena.Update(ClockFrame);
var angle = SignedTurn(before, player.Direction);
Assert.InRange(abs(abs(angle) - abs(player.TurnRate * ClockFrame)), 0f, 1e-6f);
// A positive turn demand turns clockwise, and TurnRate is signed the way the step turned Direction.
if (abs(player.TurnRate) > 1e-4f)
{
turned++;
Assert.True(player.TurnRate > 0 == dot(player.Direction, before.Rotate(ItemRotation.Clockwise)) > 0);
}
}
Assert.True(turned > 60, "the held turn turned the ship");
// A write to Direction between steps (staging, a load, a wormhole exit) is not a turn.
player.Command.Turn = TurnCommand.OfRate(0);
player.Command.Movement = float2(0, 0); // the drives are idled so the Direction write below is tested alone
for (var step = 0; step < 600 && abs(player.TurnRate) > 0; step++) arena.Update(ClockFrame);
player.Direction = normalize(float2(3, -4));
arena.Update(ClockFrame);
Assert.Equal(0f, player.TurnRate);
}
[Fact]
public void DrawAheadIsTheStateAtZeroLead()
{
var (arena, player) = LaunchDuel();
for (var step = 0; step < 120; step++) arena.Update(ClockFrame);
var before = Snapshot(arena, new List<string>());
var zero = DrawAhead.Position(player, 0);
Assert.Equal(player.Position.x, zero.x);
Assert.Equal(player.Position.y, zero.y);
Assert.Equal(player.Position.z, zero.z);
var rotation = DrawAhead.Rotation(player, 0);
Assert.Equal(1f, abs(rotation.x * player.Rotation.x + rotation.y * player.Rotation.y + rotation.z * player.Rotation.z + rotation.w * player.Rotation.w), 5);
const float lead = .007f;
var ahead = DrawAhead.Position(player, lead);
Assert.Equal(player.Position.x + player.Velocity.x * lead, ahead.x, 6);
Assert.Equal(player.RideHeight(float2(ahead.x, ahead.z), player.Zone.ExactTime + lead), ahead.y, 4);
Assert.Equal(player.Position.z + player.Velocity.y * lead, ahead.z, 6);
Assert.Equal(before, Snapshot(arena, new List<string>()));
}
// A pose the step scripts (the wormhole animation) is drawn where the step put it, at any lead.
[Fact]
public void ScriptedPosesAreNotDrawnAhead()
{
var (arena, player) = LaunchDuel();
for (var step = 0; step < 120; step++) arena.Update(ClockFrame);
player.EnterWormhole(player.Position.xz + float2(50, 0));
arena.Update(ClockFrame);
Assert.True(player.WormholeAnimationInProgress);
Assert.True(length(player.Velocity) > .01f, "the ship carries speed into the animation");
var position = DrawAhead.Position(player, ClockFrame);
Assert.Equal(player.Position.x, position.x);
Assert.Equal(player.Position.y, position.y);
Assert.Equal(player.Position.z, position.z);
var rotation = DrawAhead.Rotation(player, ClockFrame);
Assert.Equal(player.Rotation.x, rotation.x);
Assert.Equal(player.Rotation.y, rotation.y);
Assert.Equal(player.Rotation.z, rotation.z);
Assert.Equal(player.Rotation.w, rotation.w);
}
// The turn a presenter draws ahead is the turn the next step makes: a ship held in a steady turn, drawn one whole
// step ahead of the pose it was last given, is where the sim puts that pose next.
[Fact]
public void DrawnRotationFollowsTheTurn()
{
var (arena, player) = LaunchDuel();
player.Command.Turn = TurnCommand.OfRate(1f);
for (var step = 0; step < 300; step++) arena.Update(ClockFrame);
var rate = player.TurnRate;
Assert.True(abs(rate) > .05f, "the ship is turning");
var drawn = DrawAhead.Rotation(player, ClockFrame);
arena.Update(ClockFrame);
Assert.True(abs(player.TurnRate - rate) < 1e-3f * abs(rate), "the turn is steady");
var next = player.Rotation;
var error = 2 * acos(min(1f, abs(drawn.x * next.x + drawn.y * next.y + drawn.z * next.z + drawn.w * next.w)));
var turn = abs(rate * ClockFrame);
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");
}
}