Aetheria/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs
soul 37a7bb76f7 Scenario tests step the arena at SimClock.GameStep, the rate the game runs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:09:42 +02:00

185 lines
12 KiB
C#

using System.Linq;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// The arenas the operator flies to check the evasion term and the articulated barrel (map: Scenarios for operator
// checks). Each test launches the scenario the way New Game does and reads what it staged, so a script that stops
// staging what its brief claims fails here.
public sealed partial class RunStartTests
{
// Crossing: the demo's cast; projector and Cell Blaster fitted in separate weapon groups with the Fire Control Array
// aboard; an unpiloted hostile Djinni, coasting at the Djinni's speed cap, that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's
// arc, short of its Range and beyond three quarters of it.
[Fact]
public void CrossingConditions()
{
var (galaxy, arena, staged, failures) = Launch(new Crossing(), Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.Equal(DemoTerminus.Cast.ZoneCount, galaxy.Zones.Length);
Assert.Equal(4, galaxy.HomeZones.Count);
var player = staged.Player;
var projector = player.Equipment.Single(item => item.Data.Name == "Lightning Projector");
var small = player.Equipment.Single(item => item.Data.Name == "Cell Blaster");
Assert.Contains(player.Equipment, item => item.Data.Name == "Fire Control Array");
var groupOf = new System.Func<EquippedItem, int>(item => System.Array.FindIndex(player.WeaponGroups, group => group.items.Contains(item)));
Assert.True(groupOf(projector) >= 0 && groupOf(small) >= 0, "both guns are in a weapon group");
Assert.NotEqual(groupOf(projector), groupOf(small));
Assert.Equal(2, staged.Entities.Count);
var crosser = staged.Entities[0];
var waiting = staged.Entities[1];
Assert.IsType<Ship>(crosser);
Assert.IsType<Ship>(waiting);
Assert.Equal(("Djinni", "Djinni"), (_items.GetData(crosser.Hull).Name, _items.GetData(waiting.Hull).Name));
Assert.Contains(crosser.Equipment, item => item.Behaviors.OfType<Weapon>().Any());
Assert.True(player.IsHostileTo(crosser) && crosser.IsHostileTo(player), "the crosser is hostile both ways");
Assert.Equal(0, AgentsOf(arena, crosser));
var toCrosser = crosser.Position - player.Position;
Assert.True(length(toCrosser) < 400f, "the crosser starts inside 400 m");
Assert.True(toCrosser.z > 0, "the crosser starts ahead");
Assert.True(abs(dot(crosser.Direction, player.Direction)) <= sin(radians(30f)) + 1e-4f,
"the crosser faces within 30 degrees of perpendicular to the player's bow");
// It is placed moving: toward the bow line at most of the Djinni's 50 m/s cap, so its run is its own and not only the
// arena's pull on the player.
var crosserVelocity = ((Ship) crosser).Velocity;
Assert.True(crosserVelocity.x * toCrosser.x < 0, "the crosser starts moving toward the bow line");
Assert.True(abs(crosserVelocity.x) >= 40f, "the crosser's speed toward the bow line is most of the Djinni's speed cap");
Assert.True(length(crosserVelocity) >= 40f, "the crosser starts at most of the Djinni's speed cap");
Assert.False(player.IsHostileTo(waiting) || waiting.IsHostileTo(player), "the waiting Djinni is neutral");
Assert.Equal(0, AgentsOf(arena, waiting));
var toWaiting = waiting.Position - player.Position;
EvSettle(player); // a weapon's Range is read once the ship's stats have settled; the geometry above is as staged
var range = projector.Behaviors.OfType<Weapon>().Single().Range;
Assert.InRange(range, Crossing.ProjectorRange - 1f, Crossing.ProjectorRange + 1f); // the constant the waiting place is 80% of
Assert.True(FireControl.InArc(projector, toWaiting), "the waiting Djinni is inside the Lightning Projector's arc");
Assert.True(length(toWaiting) <= range, "the waiting Djinni is inside the Lightning Projector's Range");
Assert.True(length(toWaiting) > .75f * range, "the waiting Djinni is beyond three quarters of the Lightning Projector's Range");
// The crossing is what the sim does with that staging, read in the player's frame: the arena's gravity acts from the
// first tick, so the t=0 facing proves nothing. Within CrossingSeconds the crosser's bearing sweeps from port to
// starboard while it is ahead and inside CrossingMetres, and both guns bear on it. The pass is made by a coasting
// player: the loop gives no input, and the Brief tells the operator to fly it that way.
Assert.Equal(float2.zero, player.MovementDirection);
var pass = FlyPass(arena, player, crosser);
Assert.True(pass.Swept, $"the crosser's bearing never swept across the bow within {CrossingSeconds} s");
Assert.True(pass.Ahead.y > 0, "the crosser passes the bow line ahead of the player, not astern");
Assert.True(length(pass.Ahead) < CrossingMetres, "the crosser passes inside the stated distance");
Assert.True(FireControl.InArc(small, float3(pass.Ahead.x, 0, pass.Ahead.y)), "Cell Blaster bears on the crosser as it crosses");
Assert.True(dot(FireControl.MountDirection(small), float3(player.Direction.x, 0, player.Direction.y)) > 0, "Cell Blaster mounts forward");
// At seed 112 with no input the pass is at about 3.6 s and 45 m; a seed, field or staging change that loses it moves these.
Assert.InRange(pass.Seconds, CrossingEarliest, CrossingLatest);
Assert.InRange(length(pass.Ahead), 30f, 60f);
}
// The unpiloted crosser's pass is ballistic, bent only by the field, so it does not hang on a lucky start: shifting where
// the crosser starts by up to 10 m along either axis keeps the pass inside the test's time and distance windows, fast across
// the line (relative speed at the bow line) and sweeping the player's view at a steady rate (angular rate seen from the player).
// The shifts are applied to what Crossing stages, so the window is the scenario's own.
[Fact]
public void CrossingHoldsAcrossTheStartWindow()
{
var passes = new System.Collections.Generic.List<(float2 shift, CrossingPass pass)>();
foreach (var along in new[] { true, false })
for (var step = -4; step <= 4; step++)
{
var shift = along ? float2(step * 2.5f, 0) : float2(0, step * 2.5f);
var (_, arena, staged, failures) = Launch(new Crossing(), Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
var crosser = staged.Entities[0];
crosser.Position += float3(shift.x, 0, shift.y);
passes.Add((shift, FlyPass(arena, staged.Player, crosser)));
}
foreach (var (shift, pass) in passes)
{
var at = $"crosser start shifted by ({shift.x}, {shift.y}) m";
Assert.True(pass.Swept, $"{at}: never crosses the bow within {CrossingSeconds} s");
Assert.True(pass.Ahead.y > 0 && length(pass.Ahead) < CrossingMetres, $"{at}: crosses astern or beyond {CrossingMetres} m");
Assert.InRange(pass.Seconds, CrossingEarliest, CrossingLatest);
Assert.InRange(length(pass.Ahead), 30f, 60f);
Assert.InRange(pass.RelativeSpeed, 20f, 30f);
Assert.InRange(pass.AngularRate, 0.3f, 0.7f);
}
// The shifts took effect: starting 20 m further out along the crosser's track delays the pass, and starting 20 m further
// ahead moves it 20 m further ahead (a sweep that stages the same pass eighteen times proves nothing).
var outward = passes.Take(9).Select(entry => entry.pass).ToList();
var lateral = passes.Skip(9).Select(entry => entry.pass).ToList();
Assert.True(outward[0].Seconds - outward[8].Seconds > .5f, "a start 20 m further out delays the pass by more than half a second");
Assert.InRange(lateral[8].Ahead.y - lateral[0].Ahead.y, 3f, 30f);
}
// Where the crosser's bearing from the player sweeps from port to starboard: when, where it is then (x port-to-starboard,
// y ahead), its speed relative to the player and the angular rate of its bearing in radians per second.
private readonly record struct CrossingPass(bool Swept, float Seconds, float2 Ahead, float RelativeSpeed, float AngularRate);
private static CrossingPass FlyPass(Zone arena, Ship player, Entity crosser)
{
for (var tick = 0; tick < System.MathF.Round(CrossingSeconds / SimClock.GameStep); tick++)
{
var before = crosser.Position - player.Position;
arena.Update(SimClock.GameStep);
var after = crosser.Position - player.Position;
if (before.x < 0 && after.x >= 0)
{
var at = float2(after.x, after.z);
var relative = crosser.Velocity - player.Velocity;
return new CrossingPass(true, (tick + 1) * SimClock.GameStep, at, length(relative), abs(at.x * relative.y - at.y * relative.x) / dot(at, at));
}
}
return default;
}
// Place with no velocity leaves the entity at rest: the unpiloted waiting Djinni and both Shakedown targets start with
// zero velocity, so their stated places are where they are.
[Fact]
public void PlacedWithoutVelocityStartsAtRest()
{
var (_, _, crossing, crossingFailures) = Launch(new Crossing(), Inputs(() => GalaxySeed));
Assert.True(crossingFailures.Count == 0, string.Join("; ", crossingFailures));
Assert.Equal(float2.zero, crossing.Entities[1].Velocity);
var (_, _, shakedown, shakedownFailures) = Launch(new DjinniShakedown(), Inputs(() => GalaxySeed));
Assert.True(shakedownFailures.Count == 0, string.Join("; ", shakedownFailures));
Assert.All(shakedown.Entities, entity => Assert.Equal(float2.zero, entity.Velocity));
}
private const int CrossingSeconds = 6;
private const float CrossingMetres = 150f;
private const float CrossingEarliest = 3.0f, CrossingLatest = 4.2f;
// Djinni Shakedown: the generated Djinni carries a gun on a Ballistic hardpoint (not a launcher); that mount bears on
// Ahead and not on Abeam, and both targets are neutral.
[Fact]
public void DjinniShakedownTargets()
{
var (_, _, staged, failures) = Launch(new DjinniShakedown(), Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
var player = staged.Player;
var barrels = player.Equipment.Where(item => item.Hardpoint?.Type == HardpointType.Ballistic
&& item.Behaviors.OfType<Weapon>().Any(weapon => !(weapon.WeaponData is LauncherData))).ToList();
Assert.NotEmpty(barrels);
Assert.Equal(2, staged.Entities.Count);
var ahead = staged.Entities[0];
var abeam = staged.Entities[1];
var facing = player.Direction;
var perpendicular = new float2(facing.y, -facing.x);
Assert.Equal(400f, length(ahead.Position.xz), .01f);
Assert.Equal(400f, length(abeam.Position.xz), .01f);
Assert.Equal(1f, dot(normalize(ahead.Position.xz), facing), .001f);
Assert.Equal(1f, abs(dot(normalize(abeam.Position.xz), perpendicular)), .001f);
Assert.Equal(("Longinus", "Longinus"), (_items.GetData(ahead.Hull).Name, _items.GetData(abeam.Hull).Name));
foreach (var barrel in barrels)
{
Assert.True(FireControl.InArc(barrel, ahead.Position - player.Position), $"{barrel.Data.Name} cannot bear on Ahead");
Assert.False(FireControl.InArc(barrel, abeam.Position - player.Position), $"{barrel.Data.Name} bears on Abeam");
}
Assert.False(player.IsHostileTo(ahead) || ahead.IsHostileTo(player) || player.IsHostileTo(abeam) || abeam.IsHostileTo(player),
"both targets are neutral");
}
}