Merge origin/eureka/aetheria-gravity-potential-owner (cc3249b8) into grid-lowering

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 16:44:10 +02:00
commit 0d7fc2ee3a
2 changed files with 78 additions and 29 deletions

View file

@ -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);
}
}

View file

@ -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<Weapon>().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.