From 522b5af0b49548b3c6cecb4053a342cbe3ec3427 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 05:39:41 +0200 Subject: [PATCH] ManoeuvreTrack forgets a whole window after the last change of vector; tests stop resetting it, and pin the three fixes Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 18 +++++ .../Aetheria.Shared.Tests/EvasionTermTests.cs | 69 ++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index ed4232da3..8e43e40eb 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1980,15 +1980,33 @@ public readonly struct ManoeuvreEnvelope // exponential average of the acceleration, the innovation is what departs from it (low-passed at a quarter of the // window), and the moments are the exponential average of that innovation's outer product. A steady burn and // coasting have no innovation; a weave at the window's scale does; dithering much faster than the window averages out. +// A change of vector is an acceleration that differs from the last observation's by more than ChangeThreshold. When +// none has happened for a whole window the unpredictability is over: the track forgets it outright (the moments +// return to exactly zero and the trend settles on the steady acceleration), so a ship that stops jinking stops +// evading within the window and the zero-spread early return is reachable again. public struct ManoeuvreTrack { + public const float ChangeThreshold = 0.01f; + public float2 Trend; public float2 Innovation; public float3 Moments; + public float2 Last; + public float Quiet; public void Observe(float2 acceleration, float dt, float window) { if (dt <= 0f || window <= 0f) return; + var changed = abs(acceleration.x - Last.x) > ChangeThreshold || abs(acceleration.y - Last.y) > ChangeThreshold; + Quiet = changed ? 0f : Quiet + dt; + Last = acceleration; + if (Quiet >= window) + { + Trend = acceleration; + Innovation = float2(0, 0); + Moments = float3(0, 0, 0); + return; + } var k = 1f - exp(-dt / window); var k4 = 1f - exp(-4f * dt / window); Trend += k * (acceleration - Trend); diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index a4b1bb034..b63571c3f 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -53,7 +53,7 @@ public sealed partial class RunStartTests } // Idle long enough for stats, power and the aether rotors to reach their idle state, then at rest, facing +y, - // with nothing observed. + // and, after three seconds of coasting, with nothing observed: the track forgets on its own. private static void EvSettle(Ship ship) { ship.MovementDirection = float2(0, 0); @@ -61,7 +61,6 @@ public sealed partial class RunStartTests for (var step = 0; step < 180; step++) ship.Update(EvDt); ship.Velocity = float2(0, 0); ship.Direction = float2(0, 1); - ship.Manoeuvre = default; } // One of the fixed jinks, from rest facing +y: a square wave across the heading (the jink that crosses a shooter @@ -637,4 +636,70 @@ public sealed partial class RunStartTests Assert.InRange(jinking.commit / coasting.commit, expected * .999f, expected * 1.001f); Assert.InRange(jinking.hit / coasting.hit, expected * .999f, expected * 1.001f); } + + // Finding coasting-memory: a ship that stops jinking stops evading. The track is never reset by hand here: the + // Gemini jinks for four seconds, the input is cut, and evasion must read exactly 0 within the window plus the + // drive's own spin-down (half a second), and stay 0, with the moments back at exactly zero. + [Fact] + public void AShipThatStopsJinkingStopsEvading() + { + var (_, _, _, gemini) = EvFleet(); + var window = _items.GameplaySettings.SolutionWindow; + EvSettle(gemini); + EvFly(gemini, EvJink.Lateral, 1f, 4f); + var jinking = Math.Max(FireControl.Evasion(gemini, float2(0, 1)), FireControl.Evasion(gemini, EvRight(gemini))); + Assert.True(jinking > 5f, $"the jink should evade, read {jinking}"); + + var zeroAt = -1f; + const float bound = 2f; + for (var step = 1; step * EvDt <= bound + 3f; step++) + { + gemini.Update(EvDt); + var nose = FireControl.Evasion(gemini, float2(0, 1)); + var side = FireControl.Evasion(gemini, EvRight(gemini)); + if (zeroAt < 0f && nose == 0f && side == 0f) zeroAt = step * EvDt; + if (zeroAt >= 0f) Assert.True(nose == 0f && side == 0f, $"evasion came back at {step * EvDt:F2} s: {nose}, {side}"); + } + Assert.True(zeroAt >= 0f && zeroAt <= window + .5f, $"evasion reached zero at {zeroAt:F2} s, window {window}"); + Assert.Equal(0f, gemini.Manoeuvre.Moments.x); + Assert.Equal(0f, gemini.Manoeuvre.Moments.y); + Assert.Equal(0f, gemini.Manoeuvre.Moments.z); + } + + // Finding limit-clamp-evasion: a velocity limiter's clamp is the ceiling, not a change of vector. A Longinus + // assigned four times its top speed and given no input evades nothing. + [Fact] + public void ASpeedAboveTheLimitIsNotEvasion() + { + var (_, longinus, _, _) = EvFleet(); + EvSettle(longinus); + longinus.Velocity = float2(0, 400); + for (var step = 0; step < 4 * 60; step++) longinus.Update(EvDt); + Assert.True(length(longinus.Velocity) > 1f); + Assert.Equal(0f, FireControl.Evasion(longinus, float2(0, 1))); + Assert.Equal(0f, FireControl.Evasion(longinus, EvRight(longinus))); + Assert.Equal(0f, longinus.Manoeuvre.Moments.x); + Assert.Equal(0f, longinus.Manoeuvre.Moments.z); + } + + // Finding thruster-thrust-cache: Ship's aggregates read what the thrusters push with now, not what they pushed + // with at construction. The idle Djinni's forward thrust, in newtons, is its envelope's forward acceleration + // times its mass,. + [Fact] + public void TheAggregatesReadLiveThrust() + { + var (_, _, djinni, _) = EvFleet(); + EvSettle(djinni); + djinni.Update(EvDt); + Assert.InRange(djinni.ForwardThrust, djinni.Envelope.Forward * djinni.Mass * .99f, djinni.Envelope.Forward * djinni.Mass * 1.01f); + Assert.True(djinni.ForwardThrust > 0f); + var thrusters = djinni.GetBehaviors().ToList(); + Assert.NotEmpty(thrusters); + foreach (var thruster in thrusters) + { + var stat = ((ThrusterData) thruster.Data).Thrust; + Assert.Equal(thruster.Evaluate(stat), thruster.Thrust); + Assert.True(thruster.Thrust > stat.Min * 1.1f, $"idle thrust {thruster.Thrust} against the stat minimum {stat.Min}"); + } + } }