From bf69f489a927bade578955f1057a8e991c3a2115 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 15:22:14 +0200 Subject: [PATCH 1/6] Crossing: unpiloted crosser coasting at 50 u/s; the start-window sweep (bounds to be fitted) Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Scenarios/Crossing.cs | 17 ++-- .../CrossingScenarioTests.cs | 81 ++++++++++++++----- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 880f492df..288763915 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 " + + "at 50 m/s close ahead and an unpiloted one waiting at range. The crosser starts to port and only coasts: nothing steers it " + + "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,11 @@ 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 only by the field: + // CrosserAt is the centre of the start window in which the pass holds (the sweep test shifts it by 10 m on each axis). + // 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(-180, 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 +44,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..a31270e4b 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,72 @@ 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, pass.Ahead), "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, 0f, 1000f); + Assert.InRange(pass.AngularRate, 0f, 100f); + } + } + + // 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 +142,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. From 61dc8c6d0e3cf523b5a01ac0b5475dc7e9bcd5d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 15:23:38 +0200 Subject: [PATCH 2/6] Crossing test: InArc takes a planar offset as float3 (WIP, bounds still unfitted) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index a31270e4b..bf0225368 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -68,7 +68,7 @@ public sealed partial class RunStartTests 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, pass.Ahead), "Cell Blaster bears on the crosser as it crosses"); + 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); From ced6600261037a7844be77dc9aaf3ea6c174d039 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 15:28:56 +0200 Subject: [PATCH 3/6] Crossing: start window centred at (-128, 45); relative speed and angular rate bounds from the sweep The crosser is unpiloted and launched at 50 u/s; hull drag slows it to about 25 u/s relative at the bow line. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Scenarios/Crossing.cs | 12 +++++++----- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 288763915..f0e91be00 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -5,8 +5,8 @@ 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, an unpiloted Djinni coasting across the bow " + - "at 50 m/s close ahead and an unpiloted one waiting at range. The crosser starts to port and only coasts: nothing steers it " + - "and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " + + "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."; @@ -15,11 +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 is unpiloted and placed moving at the Djinni's 50 m/s cap, so its pass is ballistic, bent only by the field: - // CrosserAt is the centre of the start window in which the pass holds (the sweep test shifts it by 10 m on each axis). + // 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(-180, 45), 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); diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index bf0225368..cc0079ebc 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -100,8 +100,8 @@ public sealed partial class RunStartTests 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, 0f, 1000f); - Assert.InRange(pass.AngularRate, 0f, 100f); + Assert.InRange(pass.RelativeSpeed, 20f, 30f); + Assert.InRange(pass.AngularRate, 0.3f, 0.7f); } } From a033c01c561f875c52cf4d0e6b8d3850d01deef0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 15:47:12 +0200 Subject: [PATCH 4/6] Crossing sweep: assert the shifts moved the pass, so a sweep that ignores them fails Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index cc0079ebc..769264ec3 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -103,6 +103,13 @@ public sealed partial class RunStartTests 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 along = passes.Take(9).Select(entry => entry.pass).ToList(); + var across = passes.Skip(9).Select(entry => entry.pass).ToList(); + Assert.True(along[0].Seconds - along[8].Seconds > .5f, "a start 20 m further out delays the pass by more than half a second"); + Assert.InRange(across[8].Ahead.y - across[0].Ahead.y, 10f, 30f); } // Where the crosser's bearing from the player sweeps from port to starboard: when, where it is then (x port-to-starboard, From aa3af5ecc025e046820d988ca9fd6cc60e424f09 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 15:48:05 +0200 Subject: [PATCH 5/6] Crossing sweep: the lateral shift moves the pass by at least 3 m (the crosser meets the player's hull below a 40 m start) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index 769264ec3..61eb186d7 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -109,7 +109,7 @@ public sealed partial class RunStartTests var along = passes.Take(9).Select(entry => entry.pass).ToList(); var across = passes.Skip(9).Select(entry => entry.pass).ToList(); Assert.True(along[0].Seconds - along[8].Seconds > .5f, "a start 20 m further out delays the pass by more than half a second"); - Assert.InRange(across[8].Ahead.y - across[0].Ahead.y, 10f, 30f); + Assert.InRange(across[8].Ahead.y - across[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, From cc3249b8c9bd9a607bc6d1dae0ae019ed75ff39f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 15:50:20 +0200 Subject: [PATCH 6/6] Crossing sweep: rename the shifted-pass lists (a name clash broke the previous two commits' build) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index 61eb186d7..a55cf3e93 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -106,10 +106,10 @@ public sealed partial class RunStartTests // 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 along = passes.Take(9).Select(entry => entry.pass).ToList(); - var across = passes.Skip(9).Select(entry => entry.pass).ToList(); - Assert.True(along[0].Seconds - along[8].Seconds > .5f, "a start 20 m further out delays the pass by more than half a second"); - Assert.InRange(across[8].Ahead.y - across[0].Ahead.y, 3f, 30f); + 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,