diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 880f492df..f0e91be00 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -4,8 +4,10 @@ using static ItemRotation; public sealed class Crossing : Scenario { public override string Name => "Crossing"; - public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, a fast Djinni crossing close ahead and an " + - "unpiloted one waiting at range. The Djinni starts to port moving across the bow, and its pilot brakes it as it closes. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely (thrusting from the start carries you past it). Verify: as the Djinni crosses close, FastBlast lands some and projector " + + public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " + + "close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " + + "its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " + + "(thrusting from the start carries you past it). Verify: as the Djinni crosses close, FastBlast lands some and projector " + "almost none; against the Djinni at range both land. Fly it again without the Fire Control Array: " + "with it the small gun lands more."; public override uint Seed => 112; @@ -13,12 +15,13 @@ public sealed class Crossing : Scenario // Where the Djinni sit: the player is at Origin facing +z. Origin is 250 m or more from every body, where the zone's // pull is weakest (about 1.4 u/s^2; the sum of the wells never drops to nothing, and at the old origin the player sat on the star). - // The crosser starts ahead and to port, facing across the bow and already moving at the Djinni's 50 m/s cap; its pilot - // brakes it as it closes, so it only just reaches the bow line, which fixes how far to port it starts (sweeping the starting - // x from -95 to -97.5 moves the pass from 3.0 s to 3.5 s; at -98 it never crosses). The waiting Djinni sits inside the Lightning - // Projector's arc at 80% of its Range. + // The crosser is unpiloted and placed moving at the Djinni's 50 m/s cap, so its pass is ballistic: bent by the field and slowed + // by hull drag (it reaches the bow line at about 25 m/s relative to the player, 3.6 s in), and nothing steers it. + // CrosserAt is the centre of the start window in which the pass holds: shifting it by 10 m along either axis keeps the pass + // inside the test's time and distance windows (CrossingHoldsAcrossTheStartWindow). + // The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range. public static readonly CultMath.float2 Origin = float2(724, 124); - public static readonly CultMath.float2 CrosserAt = Origin + float2(-96.5f, 70), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0); + public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0); public const float ProjectorRange = 1216f; // the Lightning Projector's Range in metres, 1216.4 as the fit stands settled at rest public static readonly CultMath.float2 Waiting = Origin + float2(0, ProjectorRange * .8f); @@ -43,7 +46,7 @@ public sealed class Crossing : Scenario ("Fire Control Array", int2(1, 6), Clockwise)); stage.Player(fighter, Origin, facing: float2(0, 1)); - stage.Place(stage.Generated("Djinni"), CrosserAt, facing: CrosserFacing, stance: ScenarioStance.Hostile, piloted: true, velocity: CrosserVelocity); + stage.Place(stage.Generated("Djinni"), CrosserAt, facing: CrosserFacing, stance: ScenarioStance.Hostile, velocity: CrosserVelocity); stage.Place(stage.Bare("Djinni"), Waiting); } } diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index 955c331bd..a55cf3e93 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -10,7 +10,7 @@ using float3 = CultMath.float3; 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; a piloted hostile Djinni that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's + // 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() @@ -37,7 +37,7 @@ public sealed partial class RunStartTests Assert.Contains(crosser.Equipment, item => item.Behaviors.OfType().Any()); Assert.True(player.IsHostileTo(crosser) && crosser.IsHostileTo(player), "the crosser is hostile both ways"); - Assert.Equal(1, AgentsOf(arena, crosser)); + 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"); @@ -59,34 +59,79 @@ public sealed partial class RunStartTests 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 and the - // crosser's pilot act 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. + // 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 swept = false; - var crossedAt = 0f; - var crossedMetres = 0f; - for (var tick = 0; tick < 60 * CrossingSeconds && !swept; tick++) + 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 < 60 * CrossingSeconds; tick++) { var before = crosser.Position - player.Position; arena.Update(1f / 60f); var after = crosser.Position - player.Position; if (before.x < 0 && after.x >= 0) { - swept = true; - crossedAt = (tick + 1) / 60f; - crossedMetres = length(after); - Assert.True(after.z > 0, "the crosser passes the bow line ahead of the player, not astern"); - Assert.True(length(after) < CrossingMetres, "the crosser passes inside the stated distance"); - Assert.True(FireControl.InArc(small, after), "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"); + var at = float2(after.x, after.z); + var relative = crosser.Velocity - player.Velocity; + return new CrossingPass(true, (tick + 1) / 60f, at, length(relative), abs(at.x * relative.y - at.y * relative.x) / dot(at, at)); } } - Assert.True(swept, $"the crosser's bearing never swept across the bow within {CrossingSeconds} s"); - // At seed 112 with no input the pass is at 3.2 s and 40 m (the pilot brakes the crosser as it closes, so the pass is sensitive to where it starts: Crossing.CrosserAt); a seed, field or staging change that loses it moves these. - Assert.InRange(crossedAt, 3.0f, 4.2f); - Assert.InRange(crossedMetres, 30f, 60f); + + return default; } // Place with no velocity leaves the entity at rest: the unpiloted waiting Djinni and both Shakedown targets start with @@ -104,6 +149,7 @@ public sealed partial class RunStartTests 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.